diff --git a/core/core.services.yml b/core/core.services.yml
index c7f809998a75709e6e44295feefe6297a036bc8c..da5011df77c4da8567ceb3d87cb420aa6b75f4cf 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -67,6 +67,17 @@ parameters:
 services:
   _defaults:
     autoconfigure: true
+  plugin.manager.config_action:
+    class: Drupal\Core\Config\Action\ConfigActionManager
+    parent: default_plugin_manager
+    arguments: ['@config.manager', '@config.storage', '@config.typed', '@config.factory']
+  Drupal\Core\DefaultContent\Importer:
+    autowire: true
+  Drupal\Core\DefaultContent\AdminAccountSwitcher:
+    arguments:
+      $isSuperUserAccessEnabled: '%security.enable_super_user%'
+    autowire: true
+    public: false
   # Simple cache contexts, directly derived from the request context.
   cache_context.ip:
     class: Drupal\Core\Cache\Context\IpCacheContext
@@ -385,6 +396,14 @@ services:
     public: false
     tags:
       - { name: backend_overridable }
+  config.storage.checkpoint:
+    class: Drupal\Core\Config\Checkpoint\CheckpointStorage
+    arguments: [ '@config.storage', '@config.checkpoints', '@keyvalue' ]
+  Drupal\Core\Config\Checkpoint\CheckpointStorageInterface: '@config.storage.checkpoint'
+  config.checkpoints:
+    class: Drupal\Core\Config\Checkpoint\LinearHistory
+    arguments: [ '@state', '@datetime.time' ]
+  Drupal\Core\Config\Checkpoint\CheckpointListInterface: '@config.checkpoints'
   config.import_transformer:
     class: Drupal\Core\Config\ImportStorageTransformer
     arguments: ['@event_dispatcher', '@database', '@lock', '@lock.persistent']
diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc
index a52761642d89f4af4f33dffd5eac2c0e2877fbf7..def94662f534f3ad8423d5a8c9f5ce7475e476a2 100644
--- a/core/includes/install.core.inc
+++ b/core/includes/install.core.inc
@@ -26,6 +26,8 @@
 use Drupal\Core\Installer\InstallerKernel;
 use Drupal\Core\Language\Language;
 use Drupal\Core\Language\LanguageManager;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeRunner;
 use Drupal\Core\Site\Settings;
 use Drupal\Core\StringTranslation\Translator\FileTranslation;
 use Drupal\Core\StackMiddleware\ReverseProxyMiddleware;
@@ -839,6 +841,27 @@ function install_tasks($install_state) {
       array_slice($tasks, $key, NULL, TRUE);
   }
 
+  if (!empty($install_state['parameters']['recipe'])) {
+    // The install state indicates that we are installing from a recipe.
+    $key = array_search('install_profile_modules', array_keys($tasks), TRUE);
+    unset($tasks['install_profile_modules']);
+    unset($tasks['install_profile_themes']);
+    unset($tasks['install_install_profile']);
+    $recipe_tasks = [
+      'install_recipe_required_modules' => [
+        'display_name' => t('Install required modules'),
+        'type' => 'batch',
+      ],
+      'install_recipe_batch' => [
+        'display_name' => t('Install recipe'),
+        'type' => 'batch',
+      ],
+    ];
+    $tasks = array_slice($tasks, 0, $key, TRUE) +
+      $recipe_tasks +
+      array_slice($tasks, $key, NULL, TRUE);
+  }
+
   // Now add any tasks defined by the installation profile.
   if (!empty($install_state['parameters']['profile'])) {
     // Load the profile install file, because it is not always loaded when
@@ -2548,3 +2571,71 @@ function _install_config_locale_overrides_process_batch(array $names, array $lan
   }
   $context['finished'] = 1;
 }
+
+/**
+ * Installs required modules prior to applying a recipe via the installer.
+ *
+ * @see install_tasks()
+ *
+ * @internal
+ *   All installer code is internal.
+ */
+function install_recipe_required_modules() {
+  // We need to manually trigger the installation of core-provided entity types,
+  // as those will not be handled by the module installer.
+  // @see install_profile_modules()
+  install_core_entity_type_definitions();
+
+  $batch_builder = new BatchBuilder();
+  $batch_builder
+    ->setFinishCallback([ConfigImporterBatch::class, 'finish'])
+    ->setTitle(t('Installing required modules'))
+    ->setInitMessage(t('Starting required module installation.'))
+    ->setErrorMessage(t('Required module installation has encountered an error.'));
+
+  $files = \Drupal::service('extension.list.module')->getList();
+
+  // Always install required modules first.
+  $required = [];
+
+  foreach ($files as $module => $extension) {
+    if (!empty($extension->info['required'])) {
+      $required[$module] = $extension->sort;
+    }
+  }
+  arsort($required);
+
+  // The system module is already installed. See install_base_system().
+  unset($required['system']);
+
+  foreach ($required as $module => $weight) {
+    $batch_builder->addOperation(
+      '_install_module_batch',
+      [$module, $files[$module]->info['name']],
+    );
+  }
+  return $batch_builder->toArray();
+}
+
+/**
+ * Creates a batch for the recipe system to process.
+ *
+ * @see install_tasks()
+ *
+ * @internal
+ *   This API is experimental.
+ */
+function install_recipe_batch(&$install_state) {
+  $batch_builder = new BatchBuilder();
+  $batch_builder
+    ->setTitle(t('Installing recipe'))
+    ->setInitMessage(t('Starting recipe installation.'))
+    ->setErrorMessage(t('Recipe installation has encountered an error.'));
+
+  $recipe = Recipe::createFromDirectory($install_state['parameters']['recipe']);
+  foreach (RecipeRunner::toBatchOperations($recipe) as $step) {
+    $batch_builder->addOperation(...$step);
+  }
+
+  return $batch_builder->toArray();
+}
diff --git a/core/lib/Drupal/Core/Command/InstallCommand.php b/core/lib/Drupal/Core/Command/InstallCommand.php
index ef4ec3c1080a2b017c9887d4aa3f635389596c06..1c00717cfb35760546e1b63ad88a42fbdf02baa4 100644
--- a/core/lib/Drupal/Core/Command/InstallCommand.php
+++ b/core/lib/Drupal/Core/Command/InstallCommand.php
@@ -48,11 +48,12 @@ public function __construct($class_loader) {
   protected function configure() {
     $this->setName('install')
       ->setDescription('Installs a Drupal demo site. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
-      ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
+      ->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.')
       ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in.', 'en')
       ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name.', 'Drupal')
       ->addUsage('demo_umami --langcode fr')
-      ->addUsage('standard --site-name QuickInstall');
+      ->addUsage('standard --site-name QuickInstall')
+      ->addUsage('core/recipes/standard --site-name RecipeBuiltSite');
 
     parent::configure();
   }
@@ -78,15 +79,43 @@ protected function execute(InputInterface $input, OutputInterface $output): int
       return 0;
     }
 
-    $install_profile = $input->getArgument('install-profile');
-    if ($install_profile && !$this->validateProfile($install_profile, $io)) {
-      return 1;
-    }
-    if (!$install_profile) {
+    $install_profile_or_recipe = $input->getArgument('install-profile-or-recipe');
+
+    if (!$install_profile_or_recipe) {
+      // User did not provide a recipe or install profile.
       $install_profile = $this->selectProfile($io);
     }
+    // Determine if an install profile or a recipe has been provided.
+    elseif ($this->validateProfile($install_profile_or_recipe)) {
+      // User provided an install profile.
+      $install_profile = $install_profile_or_recipe;
+    }
+    elseif ($this->validateRecipe($install_profile_or_recipe)) {
+      // User provided a recipe.
+      $recipe = $install_profile_or_recipe;
+    }
+    else {
+      $error_msg = sprintf("'%s' is not a valid install profile or recipe.", $install_profile_or_recipe);
 
-    return $this->install($this->classLoader, $io, $install_profile, $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'));
+      // If it does not look like a path make suggestions based upon available
+      // profiles.
+      if (!str_contains('/', $install_profile_or_recipe)) {
+        $alternatives = [];
+        foreach (array_keys($this->getProfiles(TRUE, FALSE)) as $profile_name) {
+          $lev = levenshtein($install_profile_or_recipe, $profile_name);
+          if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile_or_recipe)) {
+            $alternatives[] = $profile_name;
+          }
+        }
+        if (!empty($alternatives)) {
+          $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
+        }
+      }
+      $io->getErrorStyle()->error($error_msg);
+      return 1;
+    }
+
+    return $this->install($this->classLoader, $io, $install_profile ?? '', $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'), $recipe ?? '');
   }
 
   /**
@@ -123,6 +152,8 @@ protected function isDrupalInstalled() {
    *   The path to install the site to, like 'sites/default'.
    * @param string $site_name
    *   The site name.
+   * @param string $recipe
+   *   The recipe to use for installing.
    *
    * @throws \Exception
    *   Thrown when failing to create the $site_path directory or settings.php.
@@ -130,7 +161,7 @@ protected function isDrupalInstalled() {
    * @return int
    *   The command exit status.
    */
-  protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name) {
+  protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name, string $recipe) {
     $password = Crypt::randomBytesBase64(12);
     $parameters = [
       'interactive' => FALSE,
@@ -165,6 +196,9 @@ protected function install($class_loader, SymfonyStyle $io, $profile, $langcode,
         ],
       ],
     ];
+    if ($recipe) {
+      $parameters['parameters']['recipe'] = $recipe;
+    }
 
     // Create the directory and settings.php if not there so that the installer
     // works.
@@ -276,29 +310,29 @@ protected function selectProfile(SymfonyStyle $io) {
    *
    * @param string $install_profile
    *   Install profile to validate.
-   * @param \Symfony\Component\Console\Style\SymfonyStyle $io
-   *   Symfony style output decorator.
    *
    * @return bool
    *   TRUE if the profile is valid, FALSE if not.
    */
-  protected function validateProfile($install_profile, SymfonyStyle $io) {
+  protected function validateProfile($install_profile): bool {
     // Allow people to install hidden and non-distribution profiles if they
     // supply the argument.
-    $profiles = $this->getProfiles(TRUE, FALSE);
-    if (!isset($profiles[$install_profile])) {
-      $error_msg = sprintf("'%s' is not a valid install profile.", $install_profile);
-      $alternatives = [];
-      foreach (array_keys($profiles) as $profile_name) {
-        $lev = levenshtein($install_profile, $profile_name);
-        if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile)) {
-          $alternatives[] = $profile_name;
-        }
-      }
-      if (!empty($alternatives)) {
-        $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
-      }
-      $io->getErrorStyle()->error($error_msg);
+    return array_key_exists($install_profile, $this->getProfiles(TRUE, FALSE));
+  }
+
+  /**
+   * Validates a user provided recipe.
+   *
+   * @param string $recipe
+   *   The path to the recipe to validate.
+   *
+   * @return bool
+   *   TRUE if the recipe exists, FALSE if not.
+   */
+  protected function validateRecipe(string $recipe): bool {
+    // It is impossible to validate a recipe fully at this point because that
+    // requires a container.
+    if (!is_dir($recipe) || !is_file($recipe . '/recipe.yml')) {
       return FALSE;
     }
     return TRUE;
diff --git a/core/lib/Drupal/Core/Command/QuickStartCommand.php b/core/lib/Drupal/Core/Command/QuickStartCommand.php
index 572f6415c20f2f23a0204c43fdd7773b0bcc28c9..6f16ccb9101256b53882c790778661b15b177404 100644
--- a/core/lib/Drupal/Core/Command/QuickStartCommand.php
+++ b/core/lib/Drupal/Core/Command/QuickStartCommand.php
@@ -28,7 +28,7 @@ class QuickStartCommand extends Command {
   protected function configure() {
     $this->setName('quick-start')
       ->setDescription('Installs a Drupal site and runs a web server. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
-      ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
+      ->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.')
       ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in. Defaults to en.', 'en')
       ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name. Defaults to Drupal.', 'Drupal')
       ->addOption('host', NULL, InputOption::VALUE_OPTIONAL, 'Provide a host for the server to run on. Defaults to 127.0.0.1.', '127.0.0.1')
@@ -36,7 +36,8 @@ protected function configure() {
       ->addOption('suppress-login', 's', InputOption::VALUE_NONE, 'Disable opening a login URL in a browser.')
       ->addUsage('demo_umami --langcode fr')
       ->addUsage('standard --site-name QuickInstall --host localhost --port 8080')
-      ->addUsage('minimal --host my-site.com --port 80');
+      ->addUsage('minimal --host my-site.com --port 80')
+      ->addUsage('core/recipes/standard --site-name MyDrupalRecipe');
 
     parent::configure();
   }
@@ -49,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
 
     $arguments = [
       'command' => 'install',
-      'install-profile' => $input->getArgument('install-profile'),
+      'install-profile-or-recipe' => $input->getArgument('install-profile-or-recipe'),
       '--langcode' => $input->getOption('langcode'),
       '--site-name' => $input->getOption('site-name'),
     ];
diff --git a/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php
new file mode 100644
index 0000000000000000000000000000000000000000..c6abf041d6c24cdd348d90b485e826c18e0c64e0
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Attribute;
+
+// cspell:ignore inflector
+use Drupal\Core\Config\Action\Exists;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+#[\Attribute(\Attribute::TARGET_METHOD)]
+final class ActionMethod {
+
+  /**
+   * @param \Drupal\Core\Config\Action\Exists $exists
+   *   Determines behavior of action depending on entity existence.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|string $adminLabel
+   *   The admin label for the user interface.
+   * @param bool|string $pluralize
+   *   Determines whether to create a pluralized version of the method to enable
+   *   the action to be called multiple times before saving the entity. The
+   *   default behavior is to create an action with a plural form as determined
+   *   by \Symfony\Component\String\Inflector\EnglishInflector::pluralize().
+   *   For example, 'grantPermission' has a pluralized version of
+   *   'grantPermissions'. If a string is provided this will be the full action
+   *   ID. For example, if the method is called 'addArray' this can be set to
+   *   'addMultipleArrays'. Set to FALSE if a pluralized version does not make
+   *   logical sense.
+   */
+  public function __construct(
+    public readonly Exists $exists = Exists::ErrorIfNotExists,
+    public readonly TranslatableMarkup|string $adminLabel = '',
+    public readonly bool|string $pluralize = TRUE
+  ) {
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php b/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php
new file mode 100644
index 0000000000000000000000000000000000000000..d8df99cf100cc8441e6384bfad65e9a6ea08b8ac
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Attribute/ConfigAction.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Attribute;
+
+use Drupal\Component\Plugin\Attribute\Plugin;
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Defines a ConfigAction attribute object.
+ *
+ * Plugin Namespace: Plugin\ConfigAction
+ *
+ * @ingroup config_action_api
+ *
+ * @internal
+ *   This API is experimental.
+ *
+ * @see \Drupal\Core\Config\Action\ConfigActionPluginInterface
+ * @see \Drupal\Core\Config\Action\ConfigActionManager
+ * @see plugin_api
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class ConfigAction extends Plugin {
+
+  /**
+   * Constructs a ConfigAction attribute.
+   *
+   * @param string $id
+   *   The plugin ID.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $admin_label
+   *   The administrative label of the config action. This is optional when
+   *   using a deriver, but in that case the deriver should add an admin label.
+   * @param string[] $entity_types
+   *   (optional) Allows action shorthand IDs for the listed config entity
+   *   types. If '*' is present in the array then it can apply to all entity
+   *   types. An empty array means that shorthand action IDs are not available
+   *   for this plugin. See ConfigActionManager::convertActionToPluginId().
+   * @param class-string|null $deriver
+   *   (optional) The deriver class.
+   *
+   * @see \Drupal\Core\Config\Action\ConfigActionManager::convertActionToPluginId()
+   */
+  public function __construct(
+    public readonly string $id,
+    public readonly ?TranslatableMarkup $admin_label = NULL,
+    public readonly array $entity_types = [],
+    public readonly ?string $deriver = NULL,
+  ) {
+    if ($this->admin_label === NULL && $this->deriver === NULL) {
+      throw new InvalidPluginDefinitionException($id, sprintf("The '%s' config action plugin must have either an admin label or a deriver", $id));
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionException.php b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php
new file mode 100644
index 0000000000000000000000000000000000000000..3ad3579b260ba643ae7b03c2a1d68f776e69a4e8
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class ConfigActionException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..cb030d6ba333c856958d5da5b9c9258ebd474d84
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php
@@ -0,0 +1,221 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+use Drupal\Component\Plugin\PluginBase;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Config\Schema\Mapping;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\Config\TypedConfigManagerInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\DefaultPluginManager;
+use Drupal\Core\Recipe\InvalidConfigException;
+use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint;
+
+/**
+ * @defgroup config_action_api Config Action API
+ * @{
+ * Information about the classes and interfaces that make up the Config Action
+ * API.
+ *
+ * Configuration actions are plugins that manipulate simple configuration or
+ * configuration entities. The configuration action plugin manager can apply
+ * configuration actions. For example, the API is leveraged by recipes to create
+ * roles if they do not exist already and grant permissions to those roles.
+ *
+ * To define a configuration action in a module you need to:
+ * - Define a Config Action plugin by creating a new class that implements the
+ *   \Drupal\Core\Config\Action\ConfigActionPluginInterface, in namespace
+ *   Plugin\ConfigAction under your module namespace. For more information about
+ *   creating plugins, see the @link plugin_api Plugin API topic. @endlink
+ * - Config action plugins use the attributes defined by
+ *  \Drupal\Core\Config\Action\Attribute\ConfigAction. See the
+ *   @link attribute Attributes topic @endlink for more information about
+ *   attributes.
+ *
+ * Further information and examples:
+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod derives
+ *   configuration actions from config entity methods which have the
+ *   \Drupal\Core\Config\Action\Attribute\ActionMethod attribute.
+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate allows you to
+ *   create configuration entities if they do not exist.
+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate allows
+ *   you to update simple configuration using a config action.
+ * @}
+ *
+ * @internal
+ *   This API is experimental.
+ */
+class ConfigActionManager extends DefaultPluginManager {
+
+  /**
+   * Constructs a new \Drupal\Core\Config\Action\ConfigActionManager object.
+   *
+   * @param \Traversable $namespaces
+   *   An object that implements \Traversable which contains the root paths
+   *   keyed by the corresponding namespace to look for plugin implementations.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   Cache backend instance to use.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler to invoke the alter hook with.
+   * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
+   *   The config manager.
+   * @param \Drupal\Core\Config\StorageInterface $configStorage
+   *   The active config storage.
+   * @param \Drupal\Core\Config\TypedConfigManagerInterface $typedConfig
+   *   The typed configuration manager service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+   *   The config factory service.
+   */
+  public function __construct(
+    \Traversable $namespaces,
+    CacheBackendInterface $cache_backend,
+    ModuleHandlerInterface $module_handler,
+    protected readonly ConfigManagerInterface $configManager,
+    protected readonly StorageInterface $configStorage,
+    protected readonly TypedConfigManagerInterface $typedConfig,
+    protected readonly ConfigFactoryInterface $configFactory,
+  ) {
+    assert($namespaces instanceof \ArrayAccess, '$namespaces can be accessed like an array');
+    // Enable this namespace to be searched for plugins.
+    $namespaces[__NAMESPACE__] = 'core/lib/Drupal/Core/Config/Action';
+
+    parent::__construct('Plugin/ConfigAction', $namespaces, $module_handler, ConfigActionPluginInterface::class, ConfigAction::class);
+
+    $this->alterInfo('config_action');
+    $this->setCacheBackend($cache_backend, 'config_action');
+  }
+
+  /**
+   * Applies a config action.
+   *
+   * @param string $action_id
+   *   The ID of the action to apply. This can be a complete configuration
+   *   action plugin ID or a shorthand action ID that is available for the
+   *   entity type of the provided configuration name.
+   * @param string $configName
+   *   The configuration name. This may be the full name of a config object, or
+   *   it may contain wildcards (to target all config entities of a specific
+   *   type, or a subset thereof). See
+   *   ConfigActionManager::getConfigNamesMatchingExpression() for more detail.
+   * @param mixed $data
+   *   The data for the action.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginException
+   *   Thrown when the config action cannot be found.
+   * @throws \Drupal\Core\Config\Action\ConfigActionException
+   *   Thrown when the config action fails to apply.
+   *
+   * @see \Drupal\Core\Config\Action\ConfigActionManager::getConfigNamesMatchingExpression()
+   */
+  public function applyAction(string $action_id, string $configName, mixed $data): void {
+    if (!$this->hasDefinition($action_id)) {
+      // Get the full plugin ID from the shorthand map, if it is available.
+      $entity_type = $this->configManager->getEntityTypeIdByName($configName);
+      if ($entity_type) {
+        $action_id = $this->getShorthandActionIdsForEntityType($entity_type)[$action_id] ?? $action_id;
+      }
+    }
+    /** @var \Drupal\Core\Config\Action\ConfigActionPluginInterface $action */
+    $action = $this->createInstance($action_id);
+    foreach ($this->getConfigNamesMatchingExpression($configName) as $name) {
+      $action->apply($name, $data);
+      $typed_config = $this->typedConfig->createFromNameAndData($name, $this->configFactory->get($name)->getRawData());
+      // All config objects are mappings.
+      assert($typed_config instanceof Mapping);
+      foreach ($typed_config->getConstraints() as $constraint) {
+        // Only validate the config if it has explicitly been marked as being
+        // validatable.
+        if ($constraint instanceof FullyValidatableConstraint) {
+          /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */
+          $violations = $typed_config->validate();
+          if (count($violations) > 0) {
+            throw new InvalidConfigException($violations, $typed_config);
+          }
+          break;
+        }
+      }
+    }
+  }
+
+  /**
+   * Gets the names of all active config objects that match an expression.
+   *
+   * @param string $expression
+   *   The expression to match. This may be the full name of a config object,
+   *   or it may contain wildcards (to target all config entities of a specific
+   *   type, or a subset thereof). For example:
+   *   - `user.role.*` would target all user roles.
+   *   - `user.role.anonymous` would target only the anonymous user role.
+   *   - `core.entity_view_display.node.*.default` would target the default
+   *     view display of every content type.
+   *   - `core.entity_form_display.*.*.default` would target the default form
+   *     display of every bundle of every entity type.
+   *   The expression MUST begin with the prefix of a config entity type --
+   *   for example, `field.field.` in the case of fields, or `user.role.` for
+   *   user roles. The prefix cannot contain wildcards.
+   *
+   * @return string[]
+   *   The names of all active config objects that match the expression.
+   *
+   * @throws \Drupal\Core\Config\Action\ConfigActionException
+   *   Thrown if the expression does not match any known config entity type's
+   *   prefix, or if the expression cannot be parsed.
+   */
+  private function getConfigNamesMatchingExpression(string $expression): array {
+    // If there are no wildcards, we can return the config name as-is.
+    if (!str_contains($expression, '.*')) {
+      return [$expression];
+    }
+
+    $entity_type = $this->configManager->getEntityTypeIdByName($expression);
+    if (empty($entity_type)) {
+      throw new ConfigActionException("No installed config entity type uses the prefix in the expression '$expression'. Either there is a typo in the expression or this recipe should install an additional module or depend on another recipe.");
+    }
+    /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
+    $entity_type = $this->configManager->getEntityTypeManager()
+      ->getDefinition($entity_type);
+    $prefix = $entity_type->getConfigPrefix();
+
+    // Convert the expression to a regular expression. We assume that * should
+    // match the characters allowed by
+    // \Drupal\Core\Config\ConfigBase::validateName(), which is permissive.
+    $expression = str_replace('\\*', '[^.:?*<>"\'\/\\\\]+', preg_quote($expression));
+    $matches = @preg_grep("/^$expression$/", $this->configStorage->listAll("$prefix."));
+    if ($matches === FALSE) {
+      throw new ConfigActionException("The expression '$expression' could not be parsed.");
+    }
+    return $matches;
+  }
+
+  /**
+   * Gets a map of shorthand action IDs to plugin IDs for an entity type.
+   *
+   * @param string $entityType
+   *   The entity type ID to get the map for.
+   *
+   * @return string[]
+   *   An array of plugin IDs keyed by shorthand action ID for the provided
+   *   entity type.
+   */
+  protected function getShorthandActionIdsForEntityType(string $entityType): array {
+    $map = [];
+    foreach ($this->getDefinitions() as $plugin_id => $definition) {
+      if (in_array($entityType, $definition['entity_types'], TRUE) || in_array('*', $definition['entity_types'], TRUE)) {
+        $regex = '/' . PluginBase::DERIVATIVE_SEPARATOR . '([^' . PluginBase::DERIVATIVE_SEPARATOR . ']*)$/';
+        $action_id = preg_match($regex, $plugin_id, $matches) ? $matches[1] : $plugin_id;
+        if (isset($map[$action_id])) {
+          throw new DuplicateConfigActionIdException(sprintf('The plugins \'%s\' and \'%s\' both resolve to the same shorthand action ID for the \'%s\' entity type', $plugin_id, $map[$action_id], $entityType));
+        }
+        $map[$action_id] = $plugin_id;
+      }
+    }
+    return $map;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..6431ec959e8c135985f878e9c1c660c48eab9ee3
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+interface ConfigActionPluginInterface {
+
+  /**
+   * Applies the config action.
+   *
+   * @param string $configName
+   *   The name of the config to apply the action to.
+   * @param mixed $value
+   *   The value for the action to use.
+   *
+   * @throws ConfigActionException
+   */
+  public function apply(string $configName, mixed $value): void;
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php
new file mode 100644
index 0000000000000000000000000000000000000000..f1d69c57a311d88e8805206f947efe412e225ed4
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+/**
+ * Exception thrown if there are conflicting shorthand action IDs.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+class DuplicateConfigActionIdException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Action/EntityMethodException.php b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php
new file mode 100644
index 0000000000000000000000000000000000000000..3f4e71ec797ce8ec882cbe3feed5129fbb40db42
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class EntityMethodException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Exists.php b/core/lib/Drupal/Core/Config/Action/Exists.php
new file mode 100644
index 0000000000000000000000000000000000000000..6649962477b7ab4ff04ab484f2343cd3152d8863
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Exists.php
@@ -0,0 +1,44 @@
+<?php
+// phpcs:ignoreFile
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+enum Exists {
+  case ErrorIfExists;
+  case ErrorIfNotExists;
+  case ReturnEarlyIfExists;
+  case ReturnEarlyIfNotExists;
+
+  /**
+   * Determines if an action should return early depending on $entity.
+   *
+   * @param string $configName
+   *   The config name supplied to the action.
+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity
+   *   The entity, if it exists.
+   *
+   * @return bool
+   *   TRUE if the action should return early, FALSE if not.
+   *
+   * @throws \Drupal\Core\Config\Action\ConfigActionException
+   *   Thrown depending on $entity and the value of $this.
+   */
+  public function returnEarly(string $configName, ?ConfigEntityInterface $entity): bool {
+    return match (TRUE) {
+      $this === self::ReturnEarlyIfExists && $entity !== NULL,
+      $this === self::ReturnEarlyIfNotExists && $entity === NULL => TRUE,
+      $this === self::ErrorIfExists && $entity !== NULL => throw new ConfigActionException(sprintf('Entity %s exists', $configName)),
+      $this === self::ErrorIfNotExists && $entity === NULL => throw new ConfigActionException(sprintf('Entity %s does not exist', $configName)),
+      default => FALSE
+    };
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php
new file mode 100644
index 0000000000000000000000000000000000000000..c5f73f243859fc2bd477589aaff562f8f0885ab7
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Config\Action\Exists;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class EntityCreateDeriver extends DeriverBase {
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    // These derivatives apply to all entity types.
+    $base_plugin_definition['entity_types'] = ['*'];
+
+    $this->derivatives['ensure_exists'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ReturnEarlyIfExists]];
+    $this->derivatives['ensure_exists']['admin_label'] = $this->t('Ensure entity exists');
+
+    $this->derivatives['create'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ErrorIfExists]];
+    $this->derivatives['create']['admin_label'] = $this->t('Entity create');
+
+    return $this->derivatives;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php
new file mode 100644
index 0000000000000000000000000000000000000000..2577d8d7b923847be2d54a61abfd8fb4a16f8cf1
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php
@@ -0,0 +1,143 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
+
+// cspell:ignore inflector
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Component\Plugin\PluginBase;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
+use Drupal\Core\Config\Action\EntityMethodException;
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\String\Inflector\EnglishInflector;
+use Symfony\Component\String\Inflector\InflectorInterface;
+
+/**
+ * Derives config action methods from attributed config entity methods.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class EntityMethodDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Inflector to pluralize words.
+   */
+  protected readonly InflectorInterface $inflector;
+
+  /**
+   * Constructs new EntityMethodDeriver.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   The entity type manager.
+   */
+  public function __construct(protected readonly EntityTypeManagerInterface $entityTypeManager) {
+    $this->inflector = new EnglishInflector();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    // Scan all the config entity classes for attributes.
+    foreach ($this->entityTypeManager->getDefinitions() as $entity_type) {
+      if ($entity_type instanceof ConfigEntityTypeInterface) {
+        $reflectionClass = new \ReflectionClass($entity_type->getClass());
+        while ($reflectionClass) {
+          foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
+            // Only process a method if it is declared on the current class.
+            // Methods on the parent class will be processed later. This allows
+            // for a parent to have an attribute and an overriding class does
+            // not need one. For example,
+            // \Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay::setComponent()
+            // and \Drupal\Core\Entity\EntityDisplayBase::setComponent().
+            if ($method->getDeclaringClass()->getName() === $reflectionClass->getName()) {
+              foreach ($method->getAttributes(ActionMethod::class) as $attribute) {
+                $this->processMethod($method, $attribute->newInstance(), $entity_type, $base_plugin_definition);
+              }
+            }
+          }
+          $reflectionClass = $reflectionClass->getParentClass();
+        }
+      }
+    }
+    return $this->derivatives;
+  }
+
+  /**
+   * Processes a method to create derivatives.
+   *
+   * @param \ReflectionMethod $method
+   *   The entity method.
+   * @param \Drupal\Core\Config\Action\Attribute\ActionMethod $action_attribute
+   *   The entity method attribute.
+   * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type
+   *   The entity type.
+   * @param array $derivative
+   *   The base plugin definition that will used to create the derivative.
+   */
+  private function processMethod(\ReflectionMethod $method, ActionMethod $action_attribute, ConfigEntityTypeInterface $entity_type, array $derivative): void {
+    $derivative['admin_label'] = $action_attribute->adminLabel ?: $this->t('@entity_type @method', ['@entity_type' => $entity_type->getLabel(), '@method' => $method->name]);
+    $derivative['constructor_args'] = [
+      'method' => $method->name,
+      'exists' => $action_attribute->exists,
+      'numberOfParams' => $method->getNumberOfParameters(),
+      'numberOfRequiredParams' => $method->getNumberOfRequiredParameters(),
+      'pluralized' => FALSE,
+    ];
+    $derivative['entity_types'] = [$entity_type->id()];
+    // Build a config action identifier from the entity type's config
+    // prefix  and the method name. For example, the Role entity adds a
+    // 'user.role:grantPermission' action.
+    $this->addDerivative($method->name, $entity_type, $derivative, $method->name);
+
+    $pluralized_name = match(TRUE) {
+      is_string($action_attribute->pluralize) => $action_attribute->pluralize,
+      $action_attribute->pluralize === FALSE => '',
+      default => $this->inflector->pluralize($method->name)[0]
+    };
+    // Add a pluralized version of the plugin.
+    if (strlen($pluralized_name) > 0) {
+      $derivative['constructor_args']['pluralized'] = TRUE;
+      $derivative['admin_label'] = $this->t('@admin_label (multiple calls)', ['@admin_label' => $derivative['admin_label']]);
+      $this->addDerivative($pluralized_name, $entity_type, $derivative, $method->name);
+    }
+  }
+
+  /**
+   * Adds a derivative.
+   *
+   * @param string $action_id
+   *   The action ID.
+   * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type
+   *   The entity type.
+   * @param array $derivative
+   *   The derivative definition.
+   * @param string $methodName
+   *   The method name.
+   */
+  private function addDerivative(string $action_id, ConfigEntityTypeInterface $entity_type, array $derivative, string $methodName): void {
+    $id = $entity_type->getConfigPrefix() . PluginBase::DERIVATIVE_SEPARATOR . $action_id;
+    if (isset($this->derivatives[$id])) {
+      throw new EntityMethodException(sprintf('Duplicate action can not be created for ID \'%s\' for %s::%s(). The existing action is for the ::%s() method', $id, $entity_type->getClass(), $methodName, $this->derivatives[$id]['constructor_args']['method']));
+    }
+    $this->derivatives[$id] = $derivative;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php
new file mode 100644
index 0000000000000000000000000000000000000000..0b30e51480598f749c023a8bf9280e546fb92606
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/PermissionsPerBundleDeriver.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Symfony\Component\DependencyInjection\Container;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class PermissionsPerBundleDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+  public function __construct(
+    private readonly EntityTypeManagerInterface $entityTypeManager,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get(EntityTypeManagerInterface::class),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    foreach ($this->entityTypeManager->getDefinitions() as $id => $entity_type) {
+      if ($entity_type->getPermissionGranularity() === 'bundle' && ($bundle_entity_type = $entity_type->getBundleEntityType()) !== NULL) {
+        // Convert unique plugin IDs, like `taxonomy_vocabulary`, into strings
+        // like `TaxonomyVocabulary`.
+        $suffix = Container::camelize($bundle_entity_type);
+
+        $this->derivatives["grantPermissionsForEach{$suffix}"] = [
+          'target_entity_type' => $id,
+        ] + $base_plugin_definition;
+      }
+    }
+    return parent::getDerivativeDefinitions($base_plugin_definition);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
new file mode 100644
index 0000000000000000000000000000000000000000..ad33645b3ddf57b2f40ceb767c57d3d8cbc2ed10
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\Action\Exists;
+use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityCreateDeriver;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+#[ConfigAction(
+  id: 'entity_create',
+  deriver: EntityCreateDeriver::class,
+)]
+final class EntityCreate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+  /**
+   * Constructs a EntityCreate object.
+   *
+   * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
+   *   The config manager.
+   * @param \Drupal\Core\Config\Action\Exists $exists
+   *   Determines behavior of action depending on entity existence.
+   */
+  public function __construct(
+    protected readonly ConfigManagerInterface $configManager,
+    protected readonly Exists $exists
+  ) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings');
+    return new static($container->get('config.manager'), ...$plugin_definition['constructor_args']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    if (!is_array($value)) {
+      throw new ConfigActionException(sprintf("The value provided to create %s must be an array", $configName));
+    }
+
+    /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */
+    $entity = $this->configManager->loadConfigEntityByName($configName);
+    if ($this->exists->returnEarly($configName, $entity)) {
+      return;
+    }
+
+    $entity_type_manager = $this->configManager->getEntityTypeManager();
+    $entity_type_id = $this->configManager->getEntityTypeIdByName($configName);
+    if ($entity_type_id === NULL) {
+      throw new ConfigActionException(sprintf("Cannot determine a config entity type from %s", $configName));
+    }
+    /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
+    $entity_type = $entity_type_manager->getDefinition($entity_type_id);
+
+    $id = substr($configName, strlen($entity_type->getConfigPrefix()) + 1);
+    $entity_type_manager
+      ->getStorage($entity_type->id())
+      ->create($value + ['id' => $id])
+      ->save();
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php
new file mode 100644
index 0000000000000000000000000000000000000000..73189f4511670c359c80d5cd4e4ad2c99561b3be
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php
@@ -0,0 +1,149 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\Action\EntityMethodException;
+use Drupal\Core\Config\Action\Exists;
+use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Makes config entity methods with the ActionMethod attribute into actions.
+ *
+ * For example, adding the ActionMethod attribute to
+ * \Drupal\user\Entity\Role::grantPermission() allows permissions to be added to
+ * roles via config actions.
+ *
+ * When calling \Drupal\Core\Config\Action\ConfigActionManager::applyAction()
+ * the $data parameter is mapped to the method's arguments using the following
+ * rules:
+ * - If $data is not an array, the method must only have one argument or one
+ *   required argument.
+ * - If $data is an array and the method only accepts a single argument, the
+ *   array will be passed to the first argument.
+ * - If $data is an array and the method accepts more than one argument, $data
+ *   will be unpacked into the method arguments.
+ *
+ * @internal
+ *   This API is experimental.
+ *
+ * @see \Drupal\Core\Config\Action\Attribute\ActionMethod
+ */
+#[ConfigAction(
+  id: 'entity_method',
+  deriver: EntityMethodDeriver::class,
+)]
+final class EntityMethod implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+  /**
+   * Constructs a EntityMethod object.
+   *
+   * @param string $pluginId
+   *   The config action plugin ID.
+   * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
+   *   The config manager.
+   * @param string $method
+   *   The method to call on the config entity.
+   * @param \Drupal\Core\Config\Action\Exists $exists
+   *   Determines behavior of action depending on entity existence.
+   * @param int $numberOfParams
+   *   The number of parameters the method has.
+   * @param int $numberOfRequiredParams
+   *   The number of required parameters the method has.
+   * @param bool $pluralized
+   *   Determines whether an array maps to multiple calls.
+   */
+  public function __construct(
+    protected readonly string $pluginId,
+    protected readonly ConfigManagerInterface $configManager,
+    protected readonly string $method,
+    protected readonly Exists $exists,
+    protected readonly int $numberOfParams,
+    protected readonly int $numberOfRequiredParams,
+    protected readonly bool $pluralized
+  ) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings');
+    return new static(
+      $plugin_id,
+      $container->get('config.manager'),
+      ...$plugin_definition['constructor_args']
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */
+    $entity = $this->configManager->loadConfigEntityByName($configName);
+    if ($this->exists->returnEarly($configName, $entity)) {
+      return;
+    }
+
+    $entity = $this->pluralized ? $this->applyPluralized($entity, $value) : $this->applySingle($entity, $value);
+    $entity->save();
+  }
+
+  /**
+   * Applies the action to entity treating the $values array as multiple calls.
+   *
+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
+   *   The entity to apply the action to.
+   * @param mixed $values
+   *   The values for the action to use.
+   *
+   * @return \Drupal\Core\Config\Entity\ConfigEntityInterface
+   *   The unsaved entity with the action applied.
+   */
+  private function applyPluralized(ConfigEntityInterface $entity, mixed $values): ConfigEntityInterface {
+    if (!is_array($values)) {
+      throw new EntityMethodException(sprintf('The pluralized entity method config action \'%s\' requires an array value in order to call %s::%s() multiple times', $this->pluginId, $entity->getEntityType()->getClass(), $this->method));
+    }
+    foreach ($values as $value) {
+      $entity = $this->applySingle($entity, $value);
+    }
+    return $entity;
+  }
+
+  /**
+   * Applies the action to entity treating the $values array a single call.
+   *
+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
+   *   The entity to apply the action to.
+   * @param mixed $value
+   *   The value for the action to use.
+   *
+   * @return \Drupal\Core\Config\Entity\ConfigEntityInterface
+   *   The unsaved entity with the action applied.
+   */
+  private function applySingle(ConfigEntityInterface $entity, mixed $value): ConfigEntityInterface {
+    // If $value is not an array then we only support calling the method if the
+    // number of parameters or required parameters is 1. If there is only 1
+    // parameter and $value is an array then assume that the parameter expects
+    // an array.
+    if (!is_array($value) || $this->numberOfParams === 1) {
+      if ($this->numberOfRequiredParams !== 1 && $this->numberOfParams !== 1) {
+        throw new EntityMethodException(sprintf('Entity method config action \'%s\' requires an array value. The number of parameters or required parameters for %s::%s() is not 1', $this->pluginId, $entity->getEntityType()->getClass(), $this->method));
+      }
+      $entity->{$this->method}($value);
+    }
+    else {
+      $entity->{$this->method}(...$value);
+    }
+    return $entity;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php
new file mode 100644
index 0000000000000000000000000000000000000000..503196869801d71e7cfff6b005fafcc59e8b2a74
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/PermissionsPerBundle.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\PermissionsPerBundleDeriver;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\user\RoleInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+#[ConfigAction(
+  id: 'permissions_per_bundle',
+  entity_types: ['user_role'],
+  deriver: PermissionsPerBundleDeriver::class,
+)]
+final class PermissionsPerBundle implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+  public function __construct(
+    private readonly ConfigManagerInterface $configManager,
+    private readonly EntityTypeBundleInfoInterface $entityTypeBundleInfo,
+    private readonly string $targetEntityType,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    assert(is_array($plugin_definition));
+    $target_entity_type = $plugin_definition['target_entity_type'];
+
+    return new static(
+      $container->get(ConfigManagerInterface::class),
+      $container->get(EntityTypeBundleInfoInterface::class),
+      $target_entity_type,
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    $role = $this->configManager->loadConfigEntityByName($configName);
+    if (!($role instanceof RoleInterface)) {
+      throw new ConfigActionException(sprintf("Cannot determine role from %s", $configName));
+    }
+
+    assert(is_string($value) || is_array($value));
+    [$permissions, $except_bundles] = self::parseValue($value);
+
+    if (empty($permissions) || !Inspector::assertAllMatch('%bundle', $permissions, TRUE)) {
+      throw new ConfigActionException(sprintf("The permissions provided %s must be an array of strings that contain '%%bundle'.", var_export($value, TRUE)));
+    }
+
+    $bundles = $this->entityTypeBundleInfo->getBundleInfo($this->targetEntityType);
+    foreach (array_keys($bundles) as $bundle_id) {
+      if (in_array($bundle_id, $except_bundles, TRUE)) {
+        continue;
+      }
+      /** @var string[] $actual_permissions */
+      $actual_permissions = str_replace('%bundle', $bundle_id, $permissions);
+      array_walk($actual_permissions, $role->grantPermission(...));
+    }
+    $role->save();
+  }
+
+  /**
+   * Parses the value supplied to ::apply().
+   *
+   * @param string|array<string|string[]> $value
+   *   One of:
+   *   - A single string (a permission template).
+   *   - An array of strings (several permission templates).
+   *   - An array with a `permissions` element, and an optional `except`
+   *     element, either of which can be an array or a string. `except` accepts
+   *     a single bundle, or a list of bundles, to exclude from the permissions
+   *     being granted.
+   *
+   * @return array<int, array<int<0, max>, array<string>|string>>
+   *   An indexed array with two elements: the array of permissions to grant,
+   *   and the list of bundles to ignore.
+   */
+  private static function parseValue(string|array $value): array {
+    if (is_string($value)) {
+      return [[$value], []];
+    }
+
+    if (array_is_list($value)) {
+      return [$value, []];
+    }
+
+    $permissions = $value['permissions'] ?? [];
+    $except_bundles = $value['except'] ?? [];
+    return [(array) $permissions, (array) $except_bundles];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php
new file mode 100644
index 0000000000000000000000000000000000000000..d6485304cc0fa0d24ccbb450f428367e42906695
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+#[ConfigAction(
+  id: 'simple_config_update',
+  admin_label: new TranslatableMarkup('Simple configuration update'),
+)]
+final class SimpleConfigUpdate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+  /**
+   * Constructs a SimpleConfigUpdate object.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+   *   The config factory.
+   */
+  public function __construct(
+    protected readonly ConfigFactoryInterface $configFactory,
+  ) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    return new static($container->get('config.factory'));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    $config = $this->configFactory->getEditable($configName);
+    // @todo https://www.drupal.org/i/3439713 Should we error if this is a
+    //   config entity?
+    if ($config->isNew()) {
+      throw new ConfigActionException(sprintf('Config %s does not exist so can not be updated', $configName));
+    }
+
+    // Expect $value to be an array whose keys are the config keys to update.
+    if (!is_array($value)) {
+      throw new ConfigActionException(sprintf('Config %s can not be updated because $value is not an array', $configName));
+    }
+    foreach ($value as $key => $value) {
+      $config->set($key, $value);
+    }
+    $config->save();
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php b/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php
new file mode 100644
index 0000000000000000000000000000000000000000..46e1247f728ed9487040a30df7b3d9b6bf1c5d39
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/Checkpoint.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * A value object to store information about a checkpoint.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class Checkpoint {
+
+  /**
+   * Constructs a checkpoint object.
+   *
+   * @param string $id
+   *   The checkpoint's ID.
+   * @param \Stringable|string $label
+   *   The human-readable label.
+   * @param int $timestamp
+   *   The timestamp when the checkpoint was created.
+   * @param string|null $parent
+   *   The ID of the checkpoint's parent.
+   */
+  public function __construct(
+    public readonly string $id,
+    public readonly \Stringable|string $label,
+    public readonly int $timestamp,
+    public readonly ?string $parent,
+  ) {
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php
new file mode 100644
index 0000000000000000000000000000000000000000..35a203b8be767c71a09b48a2c81eed2a2304cf4e
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointExistsException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * Thrown when trying to add a checkpoint with an ID that already exists.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class CheckpointExistsException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..dd3af0b07ddfff85e5685012035b8dae6475a608
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointListInterface.php
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * Maintains a list of checkpoints.
+ *
+ * @internal
+ *   This API is experimental.
+ *
+ * @see \Drupal\Core\Config\Checkpoint\Checkpoint
+ *
+ * @phpstan-extends \IteratorAggregate<string, \Drupal\Core\Config\Checkpoint\Checkpoint>
+ */
+interface CheckpointListInterface extends \IteratorAggregate, \Countable {
+
+  /**
+   * Gets the active checkpoint.
+   *
+   * @return \Drupal\Core\Config\Checkpoint\Checkpoint|null
+   *   The active checkpoint or NULL if there are no checkpoints.
+   */
+  public function getActiveCheckpoint(): ?Checkpoint;
+
+  /**
+   * Gets a checkpoint.
+   *
+   * @param string $id
+   *   The checkpoint ID.
+   *
+   * @return \Drupal\Core\Config\Checkpoint\Checkpoint
+   *   The checkpoint.
+   *
+   * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException
+   *   Thrown when the provided checkpoint does not exist.
+   */
+  public function get(string $id): Checkpoint;
+
+  /**
+   * Gets a checkpoint's parents.
+   *
+   * @param string $id
+   *   The checkpoint ID.
+   *
+   * @return iterable<string, \Drupal\Core\Config\Checkpoint\Checkpoint>
+   */
+  public function getParents(string $id): iterable;
+
+  /**
+   * Adds a new checkpoint.
+   *
+   * @param string $id
+   *   The ID of the checkpoint add.
+   * @param string|\Stringable $label
+   *   The checkpoint label.
+   *
+   * @return \Drupal\Core\Config\Checkpoint\Checkpoint
+   *   The new checkpoint, which is now at the end of the checkpoint sequence.
+   *
+   * @throws \Drupal\Core\Config\Checkpoint\CheckpointExistsException
+   *   Thrown when the ID already exists.
+   */
+  public function add(string $id, string|\Stringable $label): Checkpoint;
+
+  /**
+   * Deletes a checkpoint.
+   *
+   * @param string $id
+   *   The ID of the checkpoint to delete up to: only checkpoints after this one
+   *   will remain.
+   *
+   * @return $this
+   *
+   * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException
+   *   Thrown when provided checkpoint ID does not exist.
+   */
+  public function delete(string $id): static;
+
+  /**
+   * Deletes all checkpoints.
+   *
+   * @return $this
+   */
+  public function deleteAll(): static;
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php
new file mode 100644
index 0000000000000000000000000000000000000000..d139ce82b6b9f39e2bec261b25a29f7e880539eb
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorage.php
@@ -0,0 +1,494 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+use Drupal\Core\Config\Config;
+use Drupal\Core\Config\ConfigCollectionEvents;
+use Drupal\Core\Config\ConfigCrudEvent;
+use Drupal\Core\Config\ConfigEvents;
+use Drupal\Core\Config\ConfigRenameEvent;
+use Drupal\Core\Config\StorableConfigBase;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
+use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Provides a config storage that can make checkpoints.
+ *
+ * This storage wraps the active storage, and provides the ability to take
+ * checkpoints. Once a checkpoint has been created all configuration operations
+ * made after the checkpoint will be recorded, so it is possible to revert to
+ * original state when the checkpoint was taken.
+ *
+ * This class cannot be used to checkpoint another storage since it relies on
+ * events triggered by the configuration system in order to work. It is the
+ * responsibility of the caller to construct this class with the active storage.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class CheckpointStorage implements CheckpointStorageInterface, EventSubscriberInterface, LoggerAwareInterface {
+
+  use LoggerAwareTrait;
+
+  /**
+   * Used as prefix to a config checkpoint collection.
+   *
+   * If this code is copied in order to checkpoint a different storage then
+   * this value must be changed.
+   */
+  private const KEY_VALUE_COLLECTION_PREFIX = 'config.checkpoint.';
+
+  /**
+   * Used to store the list of collections in each checkpoint.
+   *
+   * Note this cannot be a valid configuration name.
+   *
+   * @see \Drupal\Core\Config\ConfigBase::validateName()
+   */
+  private const CONFIG_COLLECTION_KEY = 'collections';
+
+  /**
+   * The key value stores that store configuration changed for each checkpoint.
+   *
+   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface[]
+   */
+  private array $keyValueStores;
+
+  /**
+   * The checkpoint to read from.
+   *
+   * @var \Drupal\Core\Config\Checkpoint\Checkpoint|null
+   */
+  private ?Checkpoint $readFromCheckpoint = NULL;
+
+  /**
+   * Constructs a CheckpointStorage object.
+   *
+   * @param \Drupal\Core\Config\StorageInterface $activeStorage
+   *   The active configuration storage.
+   * @param \Drupal\Core\Config\Checkpoint\CheckpointListInterface $checkpoints
+   *   The list of checkpoints.
+   * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $keyValueFactory
+   *   The key value factory.
+   * @param string $collection
+   *   (optional) The configuration collection.
+   */
+  public function __construct(
+    private readonly StorageInterface $activeStorage,
+    private readonly CheckpointListInterface $checkpoints,
+    private readonly KeyValueFactoryInterface $keyValueFactory,
+    private readonly string $collection = StorageInterface::DEFAULT_COLLECTION,
+  ) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exists($name) {
+    if (count($this->checkpoints) === 0) {
+      throw new NoCheckpointsException();
+    }
+
+    foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
+      $in_checkpoint = $this->getKeyValue($checkpoint->id, $this->collection)->get($name);
+      if ($in_checkpoint !== NULL) {
+        // If $in_checkpoint is FALSE then the configuration has been deleted.
+        return $in_checkpoint !== FALSE;
+      }
+    }
+    return $this->activeStorage->exists($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function read($name) {
+    $return = $this->readMultiple([$name]);
+    return $return[$name] ?? FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function readMultiple(array $names) {
+    if (count($this->checkpoints) === 0) {
+      throw new NoCheckpointsException();
+    }
+    $return = [];
+
+    foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
+      $return = array_merge(
+        $return,
+        $this->getKeyValue($checkpoint->id, $this->collection)->getMultiple($names)
+      );
+      // Remove the read names from the list to fetch.
+      $names = array_diff($names, array_keys($return));
+      if (empty($names)) {
+        // All the configuration has been read. Nothing more to do.
+        break;
+      }
+    }
+
+    // Names not found in the checkpoints have not been modified: read from
+    // active storage.
+    if (!empty($names)) {
+      $return = array_merge(
+        $return,
+        $this->activeStorage->readMultiple($names)
+      );
+    }
+
+    // Remove any renamed or new configuration (FALSE has been recorded for
+    // these operations in the checkpoint).
+    // @see ::onConfigRename()
+    // @see ::onConfigSaveAndDelete()
+    return array_filter($return);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function encode($data) {
+    return $this->activeStorage->encode($data);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function decode($raw) {
+    return $this->activeStorage->decode($raw);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function listAll($prefix = '') {
+    if (count($this->checkpoints) === 0) {
+      throw new NoCheckpointsException();
+    }
+
+    $names = $new_configuration = [];
+
+    foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
+      $checkpoint_names = array_keys(array_filter($this->getKeyValue($checkpoint->id, $this->collection)->getAll(), function (mixed $value, string $name) use (&$new_configuration, $prefix) {
+        if ($name === static::CONFIG_COLLECTION_KEY) {
+          return FALSE;
+        }
+        // Remove any that don't start with the prefix.
+        if ($prefix !== '' && !str_starts_with($name, $prefix)) {
+          return FALSE;
+        }
+        // We've determined in a previous checkpoint that the configuration did
+        // not exist.
+        if (in_array($name, $new_configuration, TRUE)) {
+          return FALSE;
+        }
+        // If the value is FALSE then the configuration was created after the
+        // checkpoint.
+        if ($value === FALSE) {
+          $new_configuration[] = $name;
+          return FALSE;
+        }
+        return TRUE;
+      }, ARRAY_FILTER_USE_BOTH));
+      $names = array_merge($names, $checkpoint_names);
+    }
+
+    // Remove any names that did not exist prior to the checkpoint.
+    $active_names = array_diff($this->activeStorage->listAll($prefix), $new_configuration);
+
+    $names = array_unique(array_merge($names, $active_names));
+    sort($names);
+    return $names;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createCollection($collection) {
+    $collection = new self(
+      $this->activeStorage->createCollection($collection),
+      $this->checkpoints,
+      $this->keyValueFactory,
+      $collection
+    );
+    // \Drupal\Core\Config\Checkpoint\CheckpointStorage::$readFromCheckpoint is
+    // assigned by reference so that it is  consistent across all collection
+    // objects created from the same initial object.
+    $collection->readFromCheckpoint = &$this->readFromCheckpoint;
+    return $collection;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAllCollectionNames() {
+    $names = [];
+    foreach ($this->getCheckpointsToReadFrom() as $checkpoint) {
+      $names = array_merge(
+        $names,
+        $this->getKeyValue($checkpoint->id, StorageInterface::DEFAULT_COLLECTION)->get(static::CONFIG_COLLECTION_KEY, [])
+      );
+    }
+    return array_unique(array_merge($this->activeStorage->getAllCollectionNames(), $names));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCollectionName() {
+    return $this->collection;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function checkpoint(string|\Stringable $label): Checkpoint {
+    // Generate a new ID based on the state of the current active checkpoint.
+    $active_checkpoint = $this->checkpoints->getActiveCheckpoint();
+    if (!$active_checkpoint instanceof Checkpoint) {
+      // @todo https://www.drupal.org/i/3408525 Consider options for generating
+      //   a real fingerprint.
+      $id = hash('sha1', random_bytes(32));
+      return $this->checkpoints->add($id, $label);
+    }
+
+    // Determine if we need to create a new checkpoint by checking if
+    // configuration has changed since the last checkpoint.
+    $collections = $this->getAllCollectionNames();
+    $collections[] = StorageInterface::DEFAULT_COLLECTION;
+    foreach ($collections as $collection) {
+      $current_checkpoint_data[$collection] = $this->getKeyValue($active_checkpoint->id, $collection)->getAll();
+      // Remove the collections key because it is irrelevant.
+      unset($current_checkpoint_data[$collection][static::CONFIG_COLLECTION_KEY]);
+      // If there is no data in the collection then there is no need to hash
+      // the empty array.
+      if (empty($current_checkpoint_data[$collection])) {
+        unset($current_checkpoint_data[$collection]);
+      }
+    }
+
+    if (!empty($current_checkpoint_data)) {
+      // Use json_encode() here because it is both quicker and results in
+      // smaller output than serialize().
+      $id = hash('sha1', ($active_checkpoint->parent ?? '') . json_encode($current_checkpoint_data));
+      return $this->checkpoints->add($id, $label);
+    }
+
+    $this->logger?->notice('A backup checkpoint was not created because nothing has changed since the "{active}" checkpoint was created.', [
+      'active' => $active_checkpoint->label,
+    ]);
+    return $active_checkpoint;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setCheckpointToReadFrom(string|Checkpoint $checkpoint_id): static {
+    if ($checkpoint_id instanceof Checkpoint) {
+      $checkpoint_id = $checkpoint_id->id;
+    }
+    $this->readFromCheckpoint = $this->checkpoints->get($checkpoint_id);
+    return $this;
+  }
+
+  /**
+   * Gets the key value storage for the provided checkpoint.
+   *
+   * @param string $checkpoint
+   *   The checkpoint to get the key value storage for.
+   * @param string $collection
+   *   The config collection to get the key value storage for.
+   *
+   * @return \Drupal\Core\KeyValueStore\KeyValueStoreInterface
+   *   The key value storage for the provided checkpoint.
+   */
+  private function getKeyValue(string $checkpoint, string $collection): KeyValueStoreInterface {
+    $checkpoint_key = $checkpoint;
+    if ($collection !== StorageInterface::DEFAULT_COLLECTION) {
+      $checkpoint_key = $collection . '.' . $checkpoint_key;
+    }
+    return $this->keyValueStores[$checkpoint_key] ??= $this->keyValueFactory->get(self::KEY_VALUE_COLLECTION_PREFIX . $checkpoint_key);
+  }
+
+  /**
+   * Gets the checkpoints to read from.
+   *
+   * @return \Traversable<string, \Drupal\Core\Config\Checkpoint\Checkpoint>
+   *   The checkpoints, keyed by ID.
+   */
+  private function getCheckpointsToReadFrom(): \Traversable {
+    $checkpoint = $this->checkpoints->getActiveCheckpoint();
+
+    /** @var \Drupal\Core\Config\Checkpoint\Checkpoint[] $checkpoints_to_read_from */
+    $checkpoints_to_read_from = [$checkpoint];
+    if ($checkpoint->id !== $this->readFromCheckpoint?->id) {
+      // Follow ancestors to find the checkpoint to start reading from.
+      foreach ($this->checkpoints->getParents($checkpoint->id) as $checkpoint) {
+        array_unshift($checkpoints_to_read_from, $checkpoint);
+        if ($checkpoint->id === $this->readFromCheckpoint?->id) {
+          break;
+        }
+      }
+    }
+
+    // Replay in parent to child order.
+    foreach ($checkpoints_to_read_from as $checkpoint) {
+      yield $checkpoint->id => $checkpoint;
+    }
+  }
+
+  /**
+   * Updates checkpoint when configuration is saved.
+   *
+   * @param \Drupal\Core\Config\ConfigCrudEvent $event
+   *   The configuration event.
+   */
+  public function onConfigSaveAndDelete(ConfigCrudEvent $event): void {
+    $active_checkpoint = $this->checkpoints->getActiveCheckpoint();
+    if ($active_checkpoint === NULL) {
+      return;
+    }
+
+    $saved_config = $event->getConfig();
+    $collection = $saved_config->getStorage()->getCollectionName();
+    $this->storeCollectionName($collection);
+
+    $key_value = $this->getKeyValue($active_checkpoint->id, $collection);
+
+    // If we have not yet stored a checkpoint for this configuration we should.
+    if ($key_value->get($saved_config->getName()) === NULL) {
+      $original_data = $this->getOriginalConfig($saved_config);
+      // An empty array indicates that the config has to be new as a sequence
+      // cannot be the root of a config object. We need to make this assumption
+      // because $saved_config->isNew() will always return FALSE here.
+      if (empty($original_data)) {
+        $original_data = FALSE;
+      }
+      // Only save change to state if there is a change, even if it's just keys
+      // being re-ordered.
+      if ($original_data !== $saved_config->getRawData()) {
+        $key_value->set($saved_config->getName(), $original_data);
+      }
+    }
+  }
+
+  /**
+   * Updates checkpoint when configuration is saved.
+   *
+   * @param \Drupal\Core\Config\ConfigRenameEvent $event
+   *   The configuration event.
+   */
+  public function onConfigRename(ConfigRenameEvent $event): void {
+    $active_checkpoint = $this->checkpoints->getActiveCheckpoint();
+    if ($active_checkpoint === NULL) {
+      return;
+    }
+    $collection = $event->getConfig()->getStorage()->getCollectionName();
+    $this->storeCollectionName($collection);
+
+    $key_value = $this->getKeyValue($active_checkpoint->id, $collection);
+
+    $old_name = $event->getOldName();
+
+    // If we have not yet stored a checkpoint for this configuration, store a
+    // complete copy of the original configuration. Note that renames do not
+    // change data but storing the complete data allows
+    // \Drupal\Core\Config\ConfigImporter to track renames using UUIDs.
+    if ($key_value->get($old_name) === NULL) {
+      $key_value->set($old_name, $this->getOriginalConfig($event->getConfig()));
+    }
+
+    // Record that the new name did not exist prior to the checkpoint.
+    $new_name = $event->getConfig()->getName();
+    if ($key_value->get($new_name) === NULL) {
+      $key_value->set($new_name, FALSE);
+    }
+  }
+
+  /**
+   * Gets the original data from the configuration.
+   *
+   * @param \Drupal\Core\Config\StorableConfigBase $config
+   *   The config to get the original data from.
+   *
+   * @return mixed
+   *   The original data.
+   */
+  private function getOriginalConfig(StorableConfigBase $config): mixed {
+    if ($config instanceof Config) {
+      return $config->getOriginal(apply_overrides: FALSE);
+    }
+    return $config->getOriginal();
+  }
+
+  /**
+   * Stores the collection name so the storage knows its own collections.
+   *
+   * @param string $collection
+   *   The name of the collection.
+   */
+  private function storeCollectionName(string $collection): void {
+    // We do not need to store the default collection.
+    if ($collection === StorageInterface::DEFAULT_COLLECTION) {
+      return;
+    }
+
+    $key_value = $this->getKeyValue($this->checkpoints->getActiveCheckpoint()->id, StorageInterface::DEFAULT_COLLECTION);
+    $collections = $key_value->get(static::CONFIG_COLLECTION_KEY, []);
+    assert(is_array($collections));
+    if (in_array($collection, $collections, TRUE)) {
+      return;
+    }
+    $collections[] = $collection;
+    $key_value->set(static::CONFIG_COLLECTION_KEY, $collections);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    $events[ConfigEvents::SAVE][] = 'onConfigSaveAndDelete';
+    $events[ConfigEvents::DELETE][] = 'onConfigSaveAndDelete';
+    $events[ConfigEvents::RENAME][] = 'onConfigRename';
+    $events[ConfigCollectionEvents::SAVE_IN_COLLECTION][] = 'onConfigSaveAndDelete';
+    $events[ConfigCollectionEvents::DELETE_IN_COLLECTION][] = 'onConfigSaveAndDelete';
+    $events[ConfigCollectionEvents::RENAME_IN_COLLECTION][] = 'onConfigRename';
+    return $events;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function write($name, array $data): never {
+    throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function delete($name): never {
+    throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rename($name, $new_name): never {
+    throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteAll($prefix = ''): never {
+    throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a CheckpointStorage');
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe476ac92757dc0bd756d04f3bbe9ef2a1028d2e
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/CheckpointStorageInterface.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * Provides an interface for checkpoint storages.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+interface CheckpointStorageInterface extends StorageInterface {
+
+  /**
+   * Creates a checkpoint, if required, and returns the active checkpoint.
+   *
+   * If the storage determines that the current active checkpoint would contain
+   * the same information, it does not have to create a new checkpoint.
+   *
+   * @param string|\Stringable $label
+   *   The checkpoint label to use if a new checkpoint is created.
+   *
+   * @return \Drupal\Core\Config\Checkpoint\Checkpoint
+   *   The currently active checkpoint.
+   */
+  public function checkpoint(string|\Stringable $label): Checkpoint;
+
+  /**
+   * Sets the checkpoint to read from.
+   *
+   * Calling read() or readMultiple() will return the configuration data at the
+   * time of the checkpoint that was set here. If none is set, then the
+   * configuration from the initial checkpoint will be returned.
+   *
+   * @param string|\Drupal\Core\Config\Checkpoint\Checkpoint $checkpoint_id
+   *   The checkpoint ID to read from.
+   *
+   * @return $this
+   *
+   * @throws \Drupal\Core\Config\Checkpoint\UnknownCheckpointException
+   *   Thrown when the provided checkpoint does not exist.
+   */
+  public function setCheckpointToReadFrom(string|Checkpoint $checkpoint_id): static;
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php b/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php
new file mode 100644
index 0000000000000000000000000000000000000000..34047523d62378437779e1d31e941e683170e8ac
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/LinearHistory.php
@@ -0,0 +1,144 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\State\StateInterface;
+
+/**
+ * A chronological list of Checkpoint objects.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class LinearHistory implements CheckpointListInterface {
+
+  /**
+   * The store of all the checkpoint names in state.
+   */
+  private const CHECKPOINT_KEY = 'config.checkpoints';
+
+  /**
+   * The active checkpoint.
+   *
+   * In our implementation this is always the last in the list.
+   *
+   * @var \Drupal\Core\Config\Checkpoint\Checkpoint|null
+   */
+  private ?Checkpoint $activeCheckpoint;
+
+  /**
+   * The list of checkpoints, keyed by ID.
+   *
+   * @var \Drupal\Core\Config\Checkpoint\Checkpoint[]
+   */
+  private array $checkpoints;
+
+  /**
+   * Constructs a checkpoints object.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   */
+  public function __construct(
+    private readonly StateInterface $state,
+    private readonly TimeInterface $time,
+  ) {
+    $this->checkpoints = $this->state->get(self::CHECKPOINT_KEY, []);
+    $this->activeCheckpoint = end($this->checkpoints) ?: NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getActiveCheckpoint(): ?Checkpoint {
+    return $this->activeCheckpoint;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get(string $id): Checkpoint {
+    if (!isset($this->checkpoints[$id])) {
+      throw new UnknownCheckpointException(sprintf('The checkpoint "%s" does not exist', $id));
+    }
+    return $this->checkpoints[$id];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getParents(string $id): \Traversable {
+    if (!isset($this->checkpoints[$id])) {
+      throw new UnknownCheckpointException(sprintf('The checkpoint "%s" does not exist', $id));
+    }
+    $checkpoint = $this->checkpoints[$id];
+    while ($checkpoint->parent !== NULL) {
+      $checkpoint = $this->get($checkpoint->parent);
+      yield $checkpoint->id => $checkpoint;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIterator(): \Traversable {
+    return new \ArrayIterator($this->checkpoints);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function count(): int {
+    return count($this->checkpoints);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function add(string $id, string|\Stringable $label): Checkpoint {
+    if (isset($this->checkpoints[$id])) {
+      throw new CheckpointExistsException(sprintf('Cannot create a checkpoint with the ID "%s" as it already exists', $id));
+    }
+    $checkpoint = new Checkpoint($id, $label, $this->time->getCurrentTime(), $this->activeCheckpoint?->id);
+    $this->checkpoints[$checkpoint->id] = $checkpoint;
+    $this->activeCheckpoint = $checkpoint;
+    $this->state->set(self::CHECKPOINT_KEY, $this->checkpoints);
+
+    return $checkpoint;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function delete(string $id): static {
+    if (!isset($this->checkpoints[$id])) {
+      throw new UnknownCheckpointException(sprintf('Cannot delete a checkpoint with the ID "%s" as it does not exist', $id));
+    }
+
+    foreach ($this->checkpoints as $key => $checkpoint) {
+      unset($this->checkpoints[$key]);
+      if ($checkpoint->id === $id) {
+        break;
+      }
+    }
+    $this->state->set(self::CHECKPOINT_KEY, $this->checkpoints);
+
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteAll(): static {
+    $this->checkpoints = [];
+    $this->activeCheckpoint = NULL;
+    $this->state->delete(self::CHECKPOINT_KEY);
+    return $this;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php b/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php
new file mode 100644
index 0000000000000000000000000000000000000000..f0822b6210ca0f27c7618c953552d935e480fc61
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/NoCheckpointsException.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * Thrown when using the checkpoint storage with no checkpoints.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class NoCheckpointsException extends \RuntimeException {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $message = 'This storage cannot be read because there are no checkpoints';
+
+}
diff --git a/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php b/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php
new file mode 100644
index 0000000000000000000000000000000000000000..0f99855822c421f6420b517208669d4e783032b3
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Checkpoint/UnknownCheckpointException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Checkpoint;
+
+/**
+ * Thrown when trying to access a checkpoint that does not exist.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class UnknownCheckpointException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php
index feed9bbb391a1513771ceb7dc7488c8daeff2d52..e7a692cdaefad6928e83d3d327a4545300668fcb 100644
--- a/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php
+++ b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php
@@ -4,6 +4,7 @@
 
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
 use Drupal\Core\Config\Schema\SchemaIncompleteException;
 use Drupal\Core\Entity\EntityBase;
 use Drupal\Core\Config\ConfigDuplicateUUIDException;
@@ -12,6 +13,7 @@
 use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
 use Drupal\Core\Entity\SynchronizableEntityTrait;
 use Drupal\Core\Plugin\PluginDependencyTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 
 /**
  * Defines a base configuration entity class.
@@ -503,6 +505,7 @@ protected static function invalidateTagsOnDelete(EntityTypeInterface $entity_typ
   /**
    * {@inheritdoc}
    */
+  #[ActionMethod(adminLabel: new TranslatableMarkup('Set third-party setting'))]
   public function setThirdPartySetting($module, $key, $value) {
     $this->third_party_settings[$module][$key] = $value;
     return $this;
diff --git a/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php b/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php
new file mode 100644
index 0000000000000000000000000000000000000000..ac353632ab52b2b9071ad3b24e301d8b192f6c60
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/AdminAccountSwitcher.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+use Drupal\Core\Access\AccessException;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Session\AccountSwitcherInterface;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class AdminAccountSwitcher implements AccountSwitcherInterface {
+
+  public function __construct(
+    private readonly AccountSwitcherInterface $decorated,
+    private readonly EntityTypeManagerInterface $entityTypeManager,
+    private readonly bool $isSuperUserAccessEnabled,
+  ) {}
+
+  /**
+   * Switches to an administrative account.
+   *
+   * This will switch to the first available account with a role that has the
+   * `is_admin` flag. If there are no such roles, or no such users, this will
+   * try to switch to user 1 if superuser access is enabled.
+   *
+   * @return \Drupal\Core\Session\AccountInterface
+   *   The account that was switched to.
+   *
+   * @throws \Drupal\Core\Access\AccessException
+   *   Thrown if there are no users with administrative roles.
+   */
+  public function switchToAdministrator(): AccountInterface {
+    $admin_roles = $this->entityTypeManager->getStorage('user_role')
+      ->getQuery()
+      ->condition('is_admin', TRUE)
+      ->execute();
+
+    $user_storage = $this->entityTypeManager->getStorage('user');
+
+    if ($admin_roles) {
+      $accounts = $user_storage->getQuery()
+        ->accessCheck(FALSE)
+        ->condition('roles', $admin_roles, 'IN')
+        ->condition('status', 1)
+        ->sort('uid')
+        ->range(0, 1)
+        ->execute();
+    }
+    else {
+      $accounts = [];
+    }
+    $account = $user_storage->load(reset($accounts) ?: 1);
+    assert($account instanceof AccountInterface);
+
+    if (array_intersect($account->getRoles(), $admin_roles) || ((int) $account->id() === 1 && $this->isSuperUserAccessEnabled)) {
+      $this->switchTo($account);
+      return $account;
+    }
+    throw new AccessException("There are no user accounts with administrative roles.");
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function switchTo(AccountInterface $account): AccountSwitcherInterface {
+    $this->decorated->switchTo($account);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function switchBack(): AccountSwitcherInterface {
+    $this->decorated->switchBack();
+    return $this;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/Existing.php b/core/lib/Drupal/Core/DefaultContent/Existing.php
new file mode 100644
index 0000000000000000000000000000000000000000..75c5d8fff01279f42692f1023c969227c39db92d
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/Existing.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+/**
+ * Defines what to do if importing an entity that already exists (by UUID).
+ *
+ * @internal
+ *   This API is experimental.
+ */
+enum Existing {
+
+  case Error;
+  case Skip;
+
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/Finder.php b/core/lib/Drupal/Core/DefaultContent/Finder.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb35891f6ff01e70a759a46d4f51a17efc125908
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/Finder.php
@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+use Drupal\Component\Graph\Graph;
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Component\Utility\SortArray;
+use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
+use Symfony\Component\Finder\Finder as SymfonyFinder;
+
+/**
+ * Finds all default content in a directory, in dependency order.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class Finder {
+
+  /**
+   * The content entity data to import, in dependency order, keyed by entity UUID.
+   *
+   * @var array<string, array<mixed>>
+   */
+  public readonly array $data;
+
+  public function __construct(string $path) {
+    try {
+      // Scan for all YAML files in the content directory.
+      $finder = SymfonyFinder::create()
+        ->in($path)
+        ->files()
+        ->name('*.yml');
+    }
+    catch (DirectoryNotFoundException) {
+      $this->data = [];
+      return;
+    }
+
+    $graph = $files = [];
+    /** @var \Symfony\Component\Finder\SplFileInfo $file */
+    foreach ($finder as $file) {
+      /** @var array{_meta: array{uuid: string|null, depends: array<string, string>|null}} $decoded */
+      $decoded = Yaml::decode($file->getContents());
+      $decoded['_meta']['path'] = $file->getPathname();
+      $uuid = $decoded['_meta']['uuid'] ?? throw new ImportException($decoded['_meta']['path'] . ' does not have a UUID.');
+      $files[$uuid] = $decoded;
+
+      // For the graph to work correctly, every entity must be mentioned in it.
+      // This is inspired by
+      // \Drupal\Core\Config\Entity\ConfigDependencyManager::getGraph().
+      $graph += [
+        $uuid => [
+          'edges' => [],
+          'uuid' => $uuid,
+        ],
+      ];
+
+      foreach ($decoded['_meta']['depends'] ?? [] as $dependency_uuid => $entity_type) {
+        $graph[$dependency_uuid]['edges'][$uuid] = TRUE;
+        $graph[$dependency_uuid]['uuid'] = $dependency_uuid;
+      }
+    }
+    ksort($graph);
+
+    // Sort the dependency graph. The entities that are dependencies of other
+    // entities should come first.
+    $graph_object = new Graph($graph);
+    $sorted = $graph_object->searchAndSort();
+    uasort($sorted, SortArray::sortByWeightElement(...));
+
+    $entities = [];
+    foreach ($sorted as ['uuid' => $uuid]) {
+      if (array_key_exists($uuid, $files)) {
+        $entities[$uuid] = $files[$uuid];
+      }
+    }
+    $this->data = $entities;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/ImportException.php b/core/lib/Drupal/Core/DefaultContent/ImportException.php
new file mode 100644
index 0000000000000000000000000000000000000000..873b796bd2f85a1a6b76c9d1a52e9de07668754a
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/ImportException.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+/**
+ * Exception thrown when there is an error importing content.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class ImportException extends \RuntimeException {
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/Importer.php b/core/lib/Drupal/Core/DefaultContent/Importer.php
new file mode 100644
index 0000000000000000000000000000000000000000..21814ce364881e01dfc1ff01681f8f046ea1997c
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/Importer.php
@@ -0,0 +1,378 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DefaultContent;
+
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Plugin\DataType\EntityReference;
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Installer\InstallerKernel;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Drupal\file\FileInterface;
+use Drupal\link\Plugin\Field\FieldType\LinkItem;
+use Drupal\user\EntityOwnerInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+
+/**
+ * A service for handling import of content.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class Importer implements LoggerAwareInterface {
+
+  use LoggerAwareTrait;
+
+  /**
+   * The dependencies of the currently importing entity, if any.
+   *
+   * The keys are the UUIDs of the dependencies, and the values are arrays with
+   * two members: the entity type ID of the dependency, and the UUID to load.
+   *
+   * @var array<string, string[]>|null
+   */
+  private ?array $dependencies = NULL;
+
+  public function __construct(
+    private readonly EntityTypeManagerInterface $entityTypeManager,
+    private readonly AdminAccountSwitcher $accountSwitcher,
+    private readonly FileSystemInterface $fileSystem,
+    private readonly LanguageManagerInterface $languageManager,
+    private readonly EntityRepositoryInterface $entityRepository,
+  ) {}
+
+  /**
+   * Imports content entities from disk.
+   *
+   * @param \Drupal\Core\DefaultContent\Finder $content
+   *   The content finder, which has information on the entities to create
+   *   in the necessary dependency order.
+   * @param \Drupal\Core\DefaultContent\Existing $existing
+   *   (optional) What to do if one of the entities being imported already
+   *   exists, by UUID:
+   *   - \Drupal\Core\DefaultContent\Existing::Error: Throw an exception.
+   *   - \Drupal\Core\DefaultContent\Existing::Skip: Leave the existing entity
+   *     as-is.
+   *
+   * @throws \Drupal\Core\DefaultContent\ImportException
+   *   - If any of the entities being imported are not content entities.
+   *   - If any of the entities being imported already exists, by UUID, and
+   *     $existing is \Drupal\Core\DefaultContent\Existing::Error.
+   */
+  public function importContent(Finder $content, Existing $existing = Existing::Error): void {
+    if (count($content->data) === 0) {
+      return;
+    }
+
+    $account = $this->accountSwitcher->switchToAdministrator();
+
+    try {
+      /** @var array{_meta: array<mixed>} $decoded */
+      foreach ($content->data as $decoded) {
+        ['uuid' => $uuid, 'entity_type' => $entity_type_id, 'path' => $path] = $decoded['_meta'];
+        assert(is_string($uuid));
+        assert(is_string($entity_type_id));
+        assert(is_string($path));
+
+        $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+        /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */
+        if (!$entity_type->entityClassImplements(ContentEntityInterface::class)) {
+          throw new ImportException("Content entity $uuid is a '$entity_type_id', which is not a content entity type.");
+        }
+
+        $entity = $this->entityRepository->loadEntityByUuid($entity_type_id, $uuid);
+        if ($entity) {
+          if ($existing === Existing::Skip) {
+            continue;
+          }
+          else {
+            throw new ImportException("$entity_type_id $uuid already exists.");
+          }
+        }
+
+        $entity = $this->toEntity($decoded)->enforceIsNew();
+
+        // Ensure that the entity is not owned by the anonymous user.
+        if ($entity instanceof EntityOwnerInterface && empty($entity->getOwnerId())) {
+          $entity->setOwnerId($account->id());
+        }
+
+        // If a file exists in the same folder, copy it to the designated
+        // target URI.
+        if ($entity instanceof FileInterface) {
+          $this->copyFileAssociatedWithEntity(dirname($path), $entity);
+        }
+        $violations = $entity->validate();
+        if (count($violations) > 0) {
+          throw new InvalidEntityException($violations, $path);
+        }
+        $entity->save();
+      }
+    }
+    finally {
+      $this->accountSwitcher->switchBack();
+    }
+  }
+
+  /**
+   * Copies a file from default content directory to the site's file system.
+   *
+   * @param string $path
+   *   The path to the file to copy.
+   * @param \Drupal\file\FileInterface $entity
+   *   The file entity.
+   */
+  private function copyFileAssociatedWithEntity(string $path, FileInterface &$entity): void {
+    $destination = $entity->getFileUri();
+    assert(is_string($destination));
+
+    // If the source file doesn't exist, there's nothing we can do.
+    $source = $path . '/' . basename($destination);
+    if (!file_exists($source)) {
+      $this->logger?->warning("File entity %name was imported, but the associated file (@path) was not found.", [
+        '%name' => $entity->label(),
+        '@path' => $source,
+      ]);
+      return;
+    }
+
+    $copy_file = TRUE;
+    if (file_exists($destination)) {
+      $source_hash = hash_file('sha256', $source);
+      assert(is_string($source_hash));
+      $destination_hash = hash_file('sha256', $destination);
+      assert(is_string($destination_hash));
+
+      if (hash_equals($source_hash, $destination_hash) && $this->entityTypeManager->getStorage('file')->loadByProperties(['uri' => $destination]) === []) {
+        // If the file hashes match and the file is not already a managed file
+        // then do not copy a new version to the file system. This prevents
+        // re-installs during development from creating unnecessary duplicates.
+        $copy_file = FALSE;
+      }
+    }
+
+    $target_directory = dirname($destination);
+    $this->fileSystem->prepareDirectory($target_directory, FileSystemInterface::CREATE_DIRECTORY);
+    if ($copy_file) {
+      $uri = $this->fileSystem->copy($source, $destination);
+      $entity->setFileUri($uri);
+    }
+  }
+
+  /**
+   * Converts an array of content entity data to a content entity object.
+   *
+   * @param array<string, array<mixed>> $data
+   *   The entity data.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface
+   *   The unsaved entity.
+   *
+   * @throws \Drupal\Core\DefaultContent\ImportException
+   *   If the `entity_type` or `uuid` meta keys are not set.
+   */
+  private function toEntity(array $data): ContentEntityInterface {
+    if (empty($data['_meta']['entity_type'])) {
+      throw new ImportException('The entity type metadata must be specified.');
+    }
+    if (empty($data['_meta']['uuid'])) {
+      throw new ImportException('The uuid metadata must be specified.');
+    }
+
+    $is_root = FALSE;
+    // @see ::loadEntityDependency()
+    if ($this->dependencies === NULL && !empty($data['_meta']['depends'])) {
+      $is_root = TRUE;
+      foreach ($data['_meta']['depends'] as $uuid => $entity_type) {
+        assert(is_string($uuid));
+        assert(is_string($entity_type));
+        $this->dependencies[$uuid] = [$entity_type, $uuid];
+      }
+    }
+
+    ['entity_type' => $entity_type] = $data['_meta'];
+    assert(is_string($entity_type));
+    /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */
+    $entity_type = $this->entityTypeManager->getDefinition($entity_type);
+
+    $values = [
+      'uuid' => $data['_meta']['uuid'],
+    ];
+    if (!empty($data['_meta']['bundle'])) {
+      $values[$entity_type->getKey('bundle')] = $data['_meta']['bundle'];
+    }
+
+    if (!empty($data['_meta']['default_langcode'])) {
+      $data = $this->verifyNormalizedLanguage($data);
+      $values[$entity_type->getKey('langcode')] = $data['_meta']['default_langcode'];
+    }
+
+    /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    $entity = $this->entityTypeManager->getStorage($entity_type->id())->create($values);
+    foreach ($data['default'] as $field_name => $values) {
+      $this->setFieldValues($entity, $field_name, $values);
+    }
+
+    foreach ($data['translations'] ?? [] as $langcode => $translation_data) {
+      if ($this->languageManager->getLanguage($langcode)) {
+        $translation = $entity->addTranslation($langcode, $entity->toArray());
+        foreach ($translation_data as $field_name => $values) {
+          $this->setFieldValues($translation, $field_name, $values);
+        }
+      }
+    }
+
+    if ($is_root) {
+      $this->dependencies = NULL;
+    }
+    return $entity;
+  }
+
+  /**
+   * Sets field values based on the normalized data.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The content entity.
+   * @param string $field_name
+   *   The name of the field.
+   * @param array $values
+   *   The normalized data for the field.
+   */
+  private function setFieldValues(ContentEntityInterface $entity, string $field_name, array $values): void {
+    foreach ($values as $delta => $item_value) {
+      if (!$entity->get($field_name)->get($delta)) {
+        $entity->get($field_name)->appendItem();
+      }
+      /** @var \Drupal\Core\Field\FieldItemInterface $item */
+      $item = $entity->get($field_name)->get($delta);
+
+      // Update the URI based on the target UUID for link fields.
+      if (isset($item_value['target_uuid']) && $item instanceof LinkItem) {
+        $target_entity = $this->loadEntityDependency($item_value['target_uuid']);
+        if ($target_entity) {
+          $item_value['uri'] = 'entity:' . $target_entity->getEntityTypeId() . '/' . $target_entity->id();
+        }
+        unset($item_value['target_uuid']);
+      }
+
+      $serialized_property_names = $this->getCustomSerializedPropertyNames($item);
+      foreach ($item_value as $property_name => $value) {
+        if (\in_array($property_name, $serialized_property_names)) {
+          if (\is_string($value)) {
+            throw new ImportException("Received string for serialized property $field_name.$delta.$property_name");
+          }
+          $value = serialize($value);
+        }
+
+        $property = $item->get($property_name);
+
+        if ($property instanceof EntityReference) {
+          if (is_array($value)) {
+            $value = $this->toEntity($value);
+          }
+          else {
+            $value = $this->loadEntityDependency($value);
+          }
+        }
+        $property->setValue($value);
+      }
+    }
+  }
+
+  /**
+   * Gets the names of all properties the plugin treats as serialized data.
+   *
+   * This allows the field storage definition or entity type to provide a
+   * setting for serialized properties. This can be used for fields that
+   * handle serialized data themselves and do not rely on the serialized schema
+   * flag.
+   *
+   * @param \Drupal\Core\Field\FieldItemInterface $field_item
+   *   The field item.
+   *
+   * @return string[]
+   *   The property names for serialized properties.
+   *
+   * @see \Drupal\serialization\Normalizer\SerializedColumnNormalizerTrait::getCustomSerializedPropertyNames
+   */
+  private function getCustomSerializedPropertyNames(FieldItemInterface $field_item): array {
+    if ($field_item instanceof PluginInspectionInterface) {
+      $definition = $field_item->getPluginDefinition();
+      $serialized_fields = $field_item->getEntity()->getEntityType()->get('serialized_field_property_names');
+      $field_name = $field_item->getFieldDefinition()->getName();
+      if (is_array($serialized_fields) && isset($serialized_fields[$field_name]) && is_array($serialized_fields[$field_name])) {
+        return $serialized_fields[$field_name];
+      }
+      if (isset($definition['serialized_property_names']) && is_array($definition['serialized_property_names'])) {
+        return $definition['serialized_property_names'];
+      }
+    }
+    return [];
+  }
+
+  /**
+   * Loads the entity dependency by its UUID.
+   *
+   * @param string $target_uuid
+   *   The entity UUID.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface|null
+   *   The loaded entity.
+   */
+  private function loadEntityDependency(string $target_uuid): ?ContentEntityInterface {
+    if ($this->dependencies && array_key_exists($target_uuid, $this->dependencies)) {
+      $entity = $this->entityRepository->loadEntityByUuid(...$this->dependencies[$target_uuid]);
+      assert($entity instanceof ContentEntityInterface || $entity === NULL);
+      return $entity;
+    }
+    return NULL;
+  }
+
+  /**
+   * Verifies that the site knows the default language of the normalized entity.
+   *
+   * Will attempt to switch to an alternative translation or just import it
+   * with the site default language.
+   *
+   * @param array $data
+   *   The normalized entity data.
+   *
+   * @return array
+   *   The normalized entity data, possibly with altered default language
+   *   and translations.
+   */
+  private function verifyNormalizedLanguage(array $data): array {
+    $default_langcode = $data['_meta']['default_langcode'];
+    $default_language = $this->languageManager->getDefaultLanguage();
+    // Check the language. If the default language isn't known, import as one
+    // of the available translations if one exists with those values. If none
+    // exists, create the entity in the default language.
+    // During the installer, when installing with an alternative language,
+    // `en` is still the default when modules are installed so check the default language
+    // instead.
+    if (!$this->languageManager->getLanguage($default_langcode) || (InstallerKernel::installationAttempted() && $default_language->getId() !== $default_langcode)) {
+      $use_default = TRUE;
+      foreach ($data['translations'] ?? [] as $langcode => $translation_data) {
+        if ($this->languageManager->getLanguage($langcode)) {
+          $data['_meta']['default_langcode'] = $langcode;
+          $data['default'] = \array_merge($data['default'], $translation_data);
+          unset($data['translations'][$langcode]);
+          $use_default = FALSE;
+          break;
+        }
+      }
+
+      if ($use_default) {
+        $data['_meta']['default_langcode'] = $default_language->getId();
+      }
+    }
+    return $data;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php b/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php
new file mode 100644
index 0000000000000000000000000000000000000000..412f35b09c24afa0d9f34428254f144c27cdcf1a
--- /dev/null
+++ b/core/lib/Drupal/Core/DefaultContent/InvalidEntityException.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\Core\DefaultContent;
+
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+use Symfony\Component\Validator\ConstraintViolationInterface;
+
+/**
+ * Thrown if an entity being imported has validation errors.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class InvalidEntityException extends \RuntimeException {
+
+  public function __construct(public readonly EntityConstraintViolationListInterface $violations, public readonly string $filePath) {
+    $messages = [];
+
+    foreach ($violations as $violation) {
+      assert($violation instanceof ConstraintViolationInterface);
+      $messages[] = $violation->getPropertyPath() . '=' . $violation->getMessage();
+    }
+    // Example: "/path/to/file.yml: field_a=Violation 1., field_b=Violation 2.".
+    parent::__construct("$filePath: " . implode('||', $messages));
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
index 768835903f62dc778771674f05ffd05f933a0cd4..4951ef32d72d8121910cf698823fbf2889be1101 100644
--- a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
+++ b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
@@ -2,10 +2,12 @@
 
 namespace Drupal\Core\Entity;
 
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
 use Drupal\Core\Config\Entity\ConfigEntityInterface;
 use Drupal\Core\Field\FieldDefinitionInterface;
 use Drupal\Core\Entity\Display\EntityDisplayInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 
 /**
  * Provides a common base class for entity view and form displays.
@@ -345,6 +347,7 @@ public function getComponent($name) {
   /**
    * {@inheritdoc}
    */
+  #[ActionMethod(adminLabel: new TranslatableMarkup('Add component to display'))]
   public function setComponent($name, array $options = []) {
     // If no weight specified, make sure the field sinks at the bottom.
     if (!isset($options['weight'])) {
diff --git a/core/lib/Drupal/Core/Field/FieldConfigBase.php b/core/lib/Drupal/Core/Field/FieldConfigBase.php
index ca9a5b5285ead4c5285ec4cf6b7a091b670f6405..610977ec2dee8b6c9c1ee9b60ce1c1e6b69055e7 100644
--- a/core/lib/Drupal/Core/Field/FieldConfigBase.php
+++ b/core/lib/Drupal/Core/Field/FieldConfigBase.php
@@ -2,10 +2,12 @@
 
 namespace Drupal\Core\Field;
 
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
 use Drupal\Core\Entity\EntityStorageInterface;
 use Drupal\Core\Entity\FieldableEntityInterface;
 use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 
 /**
  * Base class for configurable field definitions.
@@ -327,6 +329,7 @@ public function getLabel() {
   /**
    * {@inheritdoc}
    */
+  #[ActionMethod(adminLabel: new TranslatableMarkup('Change field label'))]
   public function setLabel($label) {
     $this->label = $label;
     return $this;
diff --git a/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php
new file mode 100644
index 0000000000000000000000000000000000000000..1a46e601e6c09b40ab7b639b353e0cc9e772dd16
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php
@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\FileStorage;
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class ConfigConfigurator {
+
+  public readonly ?string $recipeConfigDirectory;
+
+  /**
+   * @param array $config
+   *   Config options for a recipe.
+   * @param string $recipe_directory
+   *   The path to the recipe.
+   * @param \Drupal\Core\Config\StorageInterface $active_configuration
+   *   The active configuration storage.
+   */
+  public function __construct(public readonly array $config, string $recipe_directory, StorageInterface $active_configuration) {
+    $this->recipeConfigDirectory = is_dir($recipe_directory . '/config') ? $recipe_directory . '/config' : NULL;
+    $recipe_storage = $this->getConfigStorage();
+    foreach ($recipe_storage->listAll() as $config_name) {
+      if ($active_data = $active_configuration->read($config_name)) {
+        // @todo https://www.drupal.org/i/3439714 Investigate if there is any
+        //   generic code in core for this.
+        unset($active_data['uuid'], $active_data['_core']);
+        if (empty($active_data['dependencies'])) {
+          unset($active_data['dependencies']);
+        }
+        $recipe_data = $recipe_storage->read($config_name);
+        if (empty($recipe_data['dependencies'])) {
+          unset($recipe_data['dependencies']);
+        }
+        // Ensure we don't get a false mismatch due to differing key order.
+        // @todo When https://www.drupal.org/project/drupal/issues/3230826 is
+        //   fixed in core, use that API instead to sort the config data.
+        self::recursiveSortByKey($active_data);
+        self::recursiveSortByKey($recipe_data);
+        if ($active_data !== $recipe_data) {
+          throw new RecipePreExistingConfigException($config_name, sprintf("The configuration '%s' exists already and does not match the recipe's configuration", $config_name));
+        }
+      }
+    }
+  }
+
+  /**
+   * Sorts an array recursively, by key, alphabetically.
+   *
+   * @param mixed[] $data
+   *   The array to sort, passed by reference.
+   *
+   * @todo Remove when https://www.drupal.org/project/drupal/issues/3230826 is
+   *   fixed in core.
+   */
+  private static function recursiveSortByKey(array &$data): void {
+    // If the array is a list, it is by definition already sorted.
+    if (!array_is_list($data)) {
+      ksort($data);
+    }
+    foreach ($data as &$value) {
+      if (is_array($value)) {
+        self::recursiveSortByKey($value);
+      }
+    }
+  }
+
+  /**
+   * Gets a config storage object for reading config from the recipe.
+   *
+   * @return \Drupal\Core\Config\StorageInterface
+   *   The  config storage object for reading config from the recipe.
+   */
+  public function getConfigStorage(): StorageInterface {
+    $storages = [];
+
+    if ($this->recipeConfigDirectory) {
+      // Config provided by the recipe should take priority over config from
+      // extensions.
+      $storages[] = new FileStorage($this->recipeConfigDirectory);
+    }
+    if (!empty($this->config['import'])) {
+      /** @var \Drupal\Core\Extension\ModuleExtensionList $module_list */
+      $module_list = \Drupal::service('extension.list.module');
+      /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_list */
+      $theme_list = \Drupal::service('extension.list.theme');
+      foreach ($this->config['import'] as $extension => $config) {
+        // If the recipe explicitly does not want to import any config from this
+        // extension, skip it.
+        if ($config === NULL) {
+          continue;
+        }
+        $path = match (TRUE) {
+          $module_list->exists($extension) => $module_list->getPath($extension),
+          $theme_list->exists($extension) => $theme_list->getPath($extension),
+          default => throw new \RuntimeException("$extension is not a theme or module")
+        };
+        $config = $config === '*' ? [] : $config;
+        $storages[] = new RecipeExtensionConfigStorage($path, $config);
+      }
+    }
+
+    return RecipeConfigStorageWrapper::createStorageFromArray($storages);
+  }
+
+  /**
+   * Determines if the recipe has any config or config actions to apply.
+   *
+   * @return bool
+   *   TRUE if the recipe has any config or config actions to apply, FALSE if
+   *   not.
+   */
+  public function hasTasks(): bool {
+    return $this->recipeConfigDirectory !== NULL || count($this->config);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/InstallConfigurator.php b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php
new file mode 100644
index 0000000000000000000000000000000000000000..abc51f5b269acc50797311a2e6d3e73c1ac8aeb0
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php
@@ -0,0 +1,120 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Extension\Dependency;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ThemeExtensionList;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class InstallConfigurator {
+
+  /**
+   * The list of modules to install.
+   *
+   * This list is sorted an includes any module dependencies of the provided
+   * extensions.
+   *
+   * @var string[]
+   */
+  public readonly array $modules;
+
+  /**
+   * The list of themes to install.
+   *
+   * This list is sorted an includes any theme dependencies of the provided
+   * extensions.
+   *
+   * @var string[]
+   */
+  public readonly array $themes;
+
+  /**
+   * @param string[] $extensions
+   *   A list of extensions for a recipe to install.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $module_list
+   *   The module list service.
+   * @param \Drupal\Core\Extension\ThemeExtensionList $theme_list
+   *   The theme list service.
+   */
+  public function __construct(array $extensions, ModuleExtensionList $module_list, ThemeExtensionList $theme_list) {
+    assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.');
+    $extensions = array_map(fn($extension) => Dependency::createFromString($extension)->getName(), $extensions);
+    $extensions = array_combine($extensions, $extensions);
+    $module_data = $module_list->reset()->getList();
+    $theme_data = $theme_list->reset()->getList();
+
+    $modules = array_intersect_key($extensions, $module_data);
+    $themes = array_intersect_key($extensions, $theme_data);
+
+    $missing_extensions = array_diff($extensions, $modules, $themes);
+
+    // Add theme module dependencies.
+    foreach ($themes as $theme => $value) {
+      $modules = array_merge($modules, array_keys($theme_data[$theme]->module_dependencies));
+    }
+
+    // Add modules that other modules depend on.
+    foreach ($modules as $module) {
+      if ($module_data[$module]->requires) {
+        $modules = array_merge($modules, array_keys($module_data[$module]->requires));
+      }
+    }
+
+    // Remove all modules that have been installed already.
+    $modules = array_diff(array_unique($modules), array_keys($module_list->getAllInstalledInfo()));
+    $modules = array_combine($modules, $modules);
+
+    // Create a sortable list of modules.
+    foreach ($modules as $name => $value) {
+      if (isset($module_data[$name])) {
+        $modules[$name] = $module_data[$name]->sort;
+      }
+      else {
+        $missing_extensions[$name] = $name;
+      }
+    }
+
+    // Add any missing base themes to the list of themes to install.
+    foreach ($themes as $theme => $value) {
+      // $theme_data[$theme]->requires contains both theme and module
+      // dependencies keyed by the extension machine names.
+      // $theme_data[$theme]->module_dependencies contains only the module
+      // dependencies keyed by the module extension machine name. Therefore,
+      // we can find the theme dependencies by finding array keys for
+      // 'requires' that are not in $module_dependencies.
+      $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $theme_data[$theme]->module_dependencies);
+      $themes = array_merge($themes, array_keys($theme_dependencies));
+    }
+
+    // Remove all themes that have been installed already.
+    $themes = array_diff(array_unique($themes), array_keys($theme_list->getAllInstalledInfo()));
+    $themes = array_combine($themes, $themes);
+
+    // Create a sortable list of themes.
+    foreach ($themes as $name => $value) {
+      if (isset($theme_data[$name])) {
+        $themes[$name] = $theme_data[$name]->sort;
+      }
+      else {
+        $missing_extensions[$name] = $name;
+      }
+    }
+
+    if (!empty($missing_extensions)) {
+      throw new RecipeMissingExtensionsException(array_values($missing_extensions));
+    }
+
+    arsort($modules);
+    arsort($themes);
+    $this->modules = array_keys($modules);
+    $this->themes = array_keys($themes);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/InvalidConfigException.php b/core/lib/Drupal/Core/Recipe/InvalidConfigException.php
new file mode 100644
index 0000000000000000000000000000000000000000..0c92a42f55cd758bc33106960dc478d9264bbecc
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/InvalidConfigException.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\Schema\Mapping;
+use Symfony\Component\Validator\ConstraintViolationList;
+
+/**
+ * Thrown if config created or changed by a recipe fails validation.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class InvalidConfigException extends \RuntimeException {
+
+  /**
+   * Constructs an InvalidConfigException object.
+   *
+   * @param \Symfony\Component\Validator\ConstraintViolationList $violations
+   *   The validation constraint violations.
+   * @param \Drupal\Core\Config\Schema\Mapping $data
+   *   A typed data wrapper around the invalid config data.
+   * @param string $message
+   *   (optional) The exception message. Defaults to the string representation
+   *   of the constraint violation list.
+   * @param int $code
+   *   (optional) The exception code. Defaults to 0.
+   * @param \Throwable|null $previous
+   *   (optional) The previous exception, if any.
+   */
+  public function __construct(
+    public readonly ConstraintViolationList $violations,
+    public readonly Mapping $data,
+    string $message = '',
+    int $code = 0,
+    ?\Throwable $previous = NULL,
+  ) {
+    parent::__construct($message ?: $this->formatMessage(), $code, $previous);
+  }
+
+  /**
+   * Formats the constraint violation list as a human-readable message.
+   *
+   * @return string
+   *   The formatted message.
+   */
+  private function formatMessage(): string {
+    $lines = [
+      sprintf('There were validation errors in %s:', $this->data->getName()),
+    ];
+    /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
+    foreach ($this->violations as $violation) {
+      $lines[] = sprintf('- %s: %s', $violation->getPropertyPath(), $violation->getMessage());
+    }
+    return implode("\n", $lines);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/Recipe.php b/core/lib/Drupal/Core/Recipe/Recipe.php
new file mode 100644
index 0000000000000000000000000000000000000000..4da07d2743e575bb53e07c73475a269c6b8d5f9b
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/Recipe.php
@@ -0,0 +1,301 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\DefaultContent\Finder;
+use Drupal\Core\Extension\Dependency;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Validation\Plugin\Validation\Constraint\RegexConstraint;
+use Symfony\Component\Validator\Constraints\All;
+use Symfony\Component\Validator\Constraints\AtLeastOneOf;
+use Symfony\Component\Validator\Constraints\Callback;
+use Symfony\Component\Validator\Constraints\Collection;
+use Symfony\Component\Validator\Constraints\IdenticalTo;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotIdenticalTo;
+use Symfony\Component\Validator\Constraints\Optional;
+use Symfony\Component\Validator\Constraints\Regex;
+use Symfony\Component\Validator\Constraints\Required;
+use Symfony\Component\Validator\Constraints\Sequentially;
+use Symfony\Component\Validator\Constraints\Type;
+use Symfony\Component\Validator\Context\ExecutionContextInterface;
+use Symfony\Component\Validator\Validation;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class Recipe {
+
+  const COMPOSER_PROJECT_TYPE = 'drupal-recipe';
+
+  public function __construct(
+    public readonly string $name,
+    public readonly string $description,
+    public readonly string $type,
+    public readonly RecipeConfigurator $recipes,
+    public readonly InstallConfigurator $install,
+    public readonly ConfigConfigurator $config,
+    public readonly Finder $content,
+    public readonly string $path,
+  ) {
+  }
+
+  /**
+   * Creates a recipe object from the provided path.
+   *
+   * @param string $path
+   *   The path to a recipe.
+   *
+   * @return static
+   *   The Recipe object.
+   */
+  public static function createFromDirectory(string $path): static {
+    $recipe_data = self::parse($path . '/recipe.yml');
+
+    $recipe_discovery = static::getRecipeDiscovery(dirname($path));
+    $recipes = new RecipeConfigurator(is_array($recipe_data['recipes']) ? $recipe_data['recipes'] : [], $recipe_discovery);
+    $install = new InstallConfigurator($recipe_data['install'], \Drupal::service('extension.list.module'), \Drupal::service('extension.list.theme'));
+    $config = new ConfigConfigurator($recipe_data['config'], $path, \Drupal::service('config.storage'));
+    $content = new Finder($path . '/content');
+    return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $content, $path);
+  }
+
+  /**
+   * Parses and validates a recipe.yml file.
+   *
+   * @param string $file
+   *   The path of a recipe.yml file.
+   *
+   * @return mixed[]
+   *   The parsed and validated data from the file.
+   *
+   * @throws \Drupal\Core\Recipe\RecipeFileException
+   *   Thrown if the recipe.yml file is unreadable, invalid, or cannot be
+   *   validated.
+   */
+  private static function parse(string $file): array {
+    if (!file_exists($file)) {
+      throw new RecipeFileException($file, "There is no $file file");
+    }
+    $recipe_contents = file_get_contents($file);
+    if (!$recipe_contents) {
+      throw new RecipeFileException($file, "$file does not exist or could not be read.");
+    }
+    // Certain parts of our validation need to be able to scan for other
+    // recipes.
+    // @see ::validateRecipeExists()
+    // @see ::validateConfigActions()
+    $discovery = self::getRecipeDiscovery(dirname($file, 2));
+
+    $constraints = new Collection([
+      'name' => new Required([
+        new Type('string'),
+        new NotBlank(),
+        // Matching `type: label` in core.data_types.schema.yml.
+        new RegexConstraint(
+          pattern: '/([^\PC])/u',
+          message: 'Recipe names cannot span multiple lines or contain control characters.',
+          match: FALSE,
+        ),
+      ]),
+      'description' => new Optional([
+        new NotBlank(),
+        // Matching `type: text` in core.data_types.schema.yml.
+        new RegexConstraint(
+          pattern: '/([^\PC\x09\x0a\x0d])/u',
+          message: 'The recipe description cannot contain control characters, only visible characters.',
+          match: FALSE,
+        ),
+      ]),
+      'type' => new Optional([
+        new Type('string'),
+        new NotBlank(),
+        // Matching `type: label` in core.data_types.schema.yml.
+        new RegexConstraint(
+          pattern: '/([^\PC])/u',
+          message: 'Recipe type cannot span multiple lines or contain control characters.',
+          match: FALSE,
+        ),
+      ]),
+      'recipes' => new Optional([
+        new All([
+          new Type('string'),
+          new NotBlank(),
+          // If recipe depends on itself, ::validateRecipeExists() will set off
+          // an infinite loop. We can avoid that by skipping that validation if
+          // the recipe depends on itself, which is what Sequentially does.
+          new Sequentially([
+            new NotIdenticalTo(
+              value: basename(dirname($file)),
+              message: 'The {{ compared_value }} recipe cannot depend on itself.',
+            ),
+            new Callback(
+              callback: self::validateRecipeExists(...),
+              payload: $discovery,
+            ),
+          ]),
+        ]),
+      ]),
+      // @todo https://www.drupal.org/i/3424603 Validate the corresponding
+      //   import.
+      'install' => new Optional([
+        new All([
+          new Type('string'),
+          new Sequentially([
+            new NotBlank(),
+            new Callback(self::validateExtensionIsAvailable(...)),
+          ]),
+        ]),
+      ]),
+      'config' => new Optional([
+        new Collection([
+          // Each entry in the `import` list can either be `*` (import all of
+          // the extension's config), or a list of config names to import from
+          // the extension.
+          // @todo https://www.drupal.org/i/3439716 Validate config file name,
+          //   if given.
+          'import' => new Optional([
+            new All([
+              new AtLeastOneOf([
+                new IdenticalTo('*'),
+                new All([
+                  new Type('string'),
+                  new NotBlank(),
+                  new Regex('/^.+\./'),
+                ]),
+              ]),
+            ]),
+          ]),
+          'actions' => new Optional([
+            new All([
+              new Type('array'),
+              new NotBlank(),
+              new Callback(
+                callback: self::validateConfigActions(...),
+                payload: $discovery,
+              ),
+            ]),
+          ]),
+        ]),
+      ]),
+      'content' => new Optional([
+        new Type('array'),
+      ]),
+    ]);
+
+    $recipe_data = Yaml::decode($recipe_contents);
+    /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */
+    $violations = Validation::createValidator()->validate($recipe_data, $constraints);
+    if (count($violations) > 0) {
+      throw RecipeFileException::fromViolationList($file, $violations);
+    }
+    $recipe_data += [
+      'description' => '',
+      'type' => '',
+      'recipes' => [],
+      'install' => [],
+      'config' => [],
+      'content' => [],
+    ];
+    return $recipe_data;
+  }
+
+  /**
+   * Validates that the value is an available module/theme (installed or not).
+   *
+   * @param string $value
+   *   The value to validate.
+   * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
+   *   The validator execution context.
+   *
+   * @see \Drupal\Core\Extension\ExtensionList::getAllAvailableInfo()
+   */
+  private static function validateExtensionIsAvailable(string $value, ExecutionContextInterface $context): void {
+    $name = Dependency::createFromString($value)->getName();
+    $all_available = \Drupal::service(ModuleExtensionList::class)->getAllAvailableInfo() + \Drupal::service(ThemeExtensionList::class)->getAllAvailableInfo();
+    if (!array_key_exists($name, $all_available)) {
+      $context->addViolation('"%extension" is not a known module or theme.', [
+        '%extension' => $name,
+      ]);
+    }
+  }
+
+  /**
+   * Validates that a recipe exists.
+   *
+   * @param string $name
+   *   The machine name of the recipe to look for.
+   * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
+   *   The validator execution context.
+   * @param \Drupal\Core\Recipe\RecipeDiscovery $discovery
+   *   A discovery object to find other recipes.
+   */
+  private static function validateRecipeExists(string $name, ExecutionContextInterface $context, RecipeDiscovery $discovery): void {
+    if (empty($name)) {
+      return;
+    }
+    try {
+      $discovery->getRecipe($name);
+    }
+    catch (UnknownRecipeException) {
+      $context->addViolation('The %name recipe does not exist.', ['%name' => $name]);
+    }
+  }
+
+  /**
+   * Gets the recipe discovery object for a recipe.
+   *
+   * @param string $recipeDirectory
+   *   The directory the contains the recipe.
+   *
+   * @return \Drupal\Core\Recipe\RecipeDiscovery
+   */
+  private static function getRecipeDiscovery(string $recipeDirectory): RecipeDiscovery {
+    return new RecipeDiscovery($recipeDirectory);
+  }
+
+  /**
+   * Validates that the corresponding extension is enabled for a config action.
+   *
+   * @param mixed $value
+   *   The config action; not used.
+   * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
+   *   The validator execution context.
+   * @param \Drupal\Core\Recipe\RecipeDiscovery $discovery
+   *   A discovery object to find other recipes.
+   */
+  private static function validateConfigActions(mixed $value, ExecutionContextInterface $context, RecipeDiscovery $discovery): void {
+    $config_name = str_replace(['[config][actions]', '[', ']'], '', $context->getPropertyPath());
+    [$config_provider] = explode('.', $config_name);
+    if ($config_provider === 'core') {
+      return;
+    }
+
+    $recipe_being_validated = $context->getRoot();
+    assert(is_array($recipe_being_validated));
+
+    $configurator = new RecipeConfigurator($recipe_being_validated['recipes'] ?? [], $discovery);
+
+    // The config provider must either be an already-installed module or theme,
+    // or an extension being installed by this recipe or a recipe it depends on.
+    $all_extensions = [
+      ...array_keys(\Drupal::service('extension.list.module')->getAllInstalledInfo()),
+      ...array_keys(\Drupal::service('extension.list.theme')->getAllInstalledInfo()),
+      ...$recipe_being_validated['install'] ?? [],
+      ...$configurator->listAllExtensions(),
+    ];
+
+    if (!in_array($config_provider, $all_extensions, TRUE)) {
+      $context->addViolation('Config actions cannot be applied to %config_name because the %config_provider extension is not installed, and is not installed by this recipe or any of the recipes it depends on.', [
+        '%config_name' => $config_name,
+        '%config_provider' => $config_provider,
+      ]);
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php b/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..3e3078d23ca21b7d404651aab79aeff0fff203a6
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeAppliedEvent.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event dispatched after a recipe has been applied.
+ *
+ * Subscribers to this event should avoid modifying config or content, because
+ * it is very likely that the recipe was applied as part of a chain of recipes,
+ * so config and content are probably about to change again. This event is best
+ * used for tasks like notifications, logging or updating a value in state.
+ */
+final class RecipeAppliedEvent extends Event {
+
+  /**
+   * Constructs a RecipeAppliedEvent object.
+   *
+   * @param \Drupal\Core\Recipe\Recipe $recipe
+   *   The recipe that was applied.
+   */
+  public function __construct(public readonly Recipe $recipe) {
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeCommand.php b/core/lib/Drupal/Core/Recipe/RecipeCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..84ee9e2d654bd64e4b9d0fc52092c0fad240d707
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeCommand.php
@@ -0,0 +1,216 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Component\Render\PlainTextOutput;
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Core\Config\ConfigImporter;
+use Drupal\Core\Config\ConfigImporterException;
+use Drupal\Core\Config\StorageComparer;
+use Drupal\Core\DrupalKernel;
+use Drupal\Core\Site\Settings;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LogLevel;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Logger\ConsoleLogger;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Applies recipe.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeCommand extends Command {
+
+  /**
+   * The class loader.
+   *
+   * @var object
+   */
+  protected $classLoader;
+
+  /**
+   * Constructs a new RecipeCommand command.
+   *
+   * @param object $class_loader
+   *   The class loader.
+   */
+  public function __construct($class_loader) {
+    parent::__construct('recipe');
+    $this->classLoader = $class_loader;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function configure(): void {
+    $this
+      ->setDescription('Applies a recipe to a site.')
+      ->addArgument('path', InputArgument::REQUIRED, 'The path to the recipe\'s folder to apply');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(InputInterface $input, OutputInterface $output): int {
+    $io = new SymfonyStyle($input, $output);
+
+    $recipe_path = $input->getArgument('path');
+    if (!is_string($recipe_path) || !is_dir($recipe_path)) {
+      $io->error(sprintf('The supplied path %s is not a directory', $recipe_path));
+    }
+    // Recipes can only be applied to an already-installed site.
+    $container = $this->boot()->getContainer();
+
+    /** @var \Drupal\Core\Config\Checkpoint\CheckpointStorageInterface $checkpoint_storage */
+    $checkpoint_storage = $container->get('config.storage.checkpoint');
+    $recipe = Recipe::createFromDirectory($recipe_path);
+    if ($checkpoint_storage instanceof LoggerAwareInterface) {
+      $logger = new ConsoleLogger($output, [
+        // The checkpoint storage logs a notice if it decides to not create a
+        // checkpoint, and we want to be sure those notices are seen even
+        // without additional verbosity.
+        LogLevel::NOTICE => OutputInterface::VERBOSITY_NORMAL,
+      ]);
+      $checkpoint_storage->setLogger($logger);
+    }
+    $backup_checkpoint = $checkpoint_storage
+      ->checkpoint("Backup before the '$recipe->name' recipe.");
+    try {
+      $steps = RecipeRunner::toBatchOperations($recipe);
+      $progress_bar = $io->createProgressBar();
+      $progress_bar->setFormat("%current%/%max% [%bar%]\n%message%\n");
+      $progress_bar->setMessage($this->toPlainString(t('Applying recipe')));
+      $progress_bar->start(count($steps));
+
+      /** @var array{message?: \Stringable|string, results: array{module?: string[], theme?: string[], content?: string[], recipe?: string[]}} $context */
+      $context = ['results' => []];
+      foreach ($steps as $step) {
+        call_user_func_array($step[0], array_merge($step[1], [&$context]));
+        if (isset($context['message'])) {
+          $progress_bar->setMessage($this->toPlainString($context['message']));
+        }
+        unset($context['message']);
+        $progress_bar->advance();
+      }
+      if ($io->isVerbose()) {
+        if (!empty($context['results']['module'])) {
+          $io->section($this->toPlainString(t('Modules installed')));
+          $modules = array_map(fn ($module) => \Drupal::service('extension.list.module')->getName($module), $context['results']['module']);
+          sort($modules, SORT_NATURAL);
+          $io->listing($modules);
+        }
+        if (!empty($context['results']['theme'])) {
+          $io->section($this->toPlainString(t('Themes installed')));
+          $themes = array_map(fn ($theme) => \Drupal::service('extension.list.theme')->getName($theme), $context['results']['theme']);
+          sort($themes, SORT_NATURAL);
+          $io->listing($themes);
+        }
+        if (!empty($context['results']['content'])) {
+          $io->section($this->toPlainString(t('Content created for recipes')));
+          $io->listing($context['results']['content']);
+        }
+        if (!empty($context['results']['recipe'])) {
+          $io->section($this->toPlainString(t('Recipes applied')));
+          $io->listing($context['results']['recipe']);
+        }
+      }
+      $io->success($this->toPlainString(t('%recipe applied successfully', ['%recipe' => $recipe->name])));
+      return 0;
+    }
+    catch (\Throwable $e) {
+      try {
+        $this->rollBackToCheckpoint($backup_checkpoint);
+      }
+      catch (ConfigImporterException $importer_exception) {
+        $io->error($importer_exception->getMessage());
+      }
+      throw $e;
+    }
+  }
+
+  /**
+   * Converts a stringable like TranslatableMarkup to a plain text string.
+   *
+   * @param \Stringable|string $text
+   *   The string to convert.
+   *
+   * @return string
+   *   The plain text string.
+   */
+  private function toPlainString(\Stringable|string $text): string {
+    return PlainTextOutput::renderFromHtml((string) $text);
+  }
+
+  /**
+   * Rolls config back to a particular checkpoint.
+   *
+   * @param \Drupal\Core\Config\Checkpoint\Checkpoint $checkpoint
+   *   The checkpoint to roll back to.
+   */
+  private function rollBackToCheckpoint(Checkpoint $checkpoint): void {
+    $container = \Drupal::getContainer();
+
+    /** @var \Drupal\Core\Config\Checkpoint\CheckpointStorageInterface $checkpoint_storage */
+    $checkpoint_storage = $container->get('config.storage.checkpoint');
+    $checkpoint_storage->setCheckpointToReadFrom($checkpoint);
+
+    $storage_comparer = new StorageComparer($checkpoint_storage, $container->get('config.storage'));
+    $storage_comparer->reset();
+
+    $config_importer = new ConfigImporter(
+      $storage_comparer,
+      $container->get('event_dispatcher'),
+      $container->get('config.manager'),
+      $container->get('lock'),
+      $container->get('config.typed'),
+      $container->get('module_handler'),
+      $container->get('module_installer'),
+      $container->get('theme_handler'),
+      $container->get('string_translation'),
+      $container->get('extension.list.module'),
+      $container->get('extension.list.theme'),
+    );
+    $config_importer->import();
+  }
+
+  /**
+   * Boots up a Drupal environment.
+   *
+   * @return \Drupal\Core\DrupalKernelInterface
+   *   The Drupal kernel.
+   *
+   * @throws \Exception
+   *   Exception thrown if kernel does not boot.
+   */
+  protected function boot() {
+    $kernel = new DrupalKernel('prod', $this->classLoader, FALSE);
+    $kernel::bootEnvironment();
+    $kernel->setSitePath($this->getSitePath());
+    Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader);
+    $kernel->boot();
+    $kernel->preHandle(Request::createFromGlobals());
+    return $kernel;
+  }
+
+  /**
+   * Gets the site path.
+   *
+   * Defaults to 'sites/default'. For testing purposes this can be overridden
+   * using the DRUPAL_DEV_SITE_PATH environment variable.
+   *
+   * @return string
+   *   The site path to use.
+   */
+  protected function getSitePath() {
+    return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default';
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php
new file mode 100644
index 0000000000000000000000000000000000000000..db0e6e24b0b6b31a6717a7be6e50dc8431eb2819
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\ConfigInstaller;
+use Drupal\Core\Config\Entity\ConfigDependencyManager;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\Installer\InstallerKernel;
+use Drupal\Core\Validation\Plugin\Validation\Constraint\FullyValidatableConstraint;
+
+/**
+ * Extends the ConfigInstaller service for recipes.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeConfigInstaller extends ConfigInstaller {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function installRecipeConfig(ConfigConfigurator $recipe_config): void {
+    $storage = $recipe_config->getConfigStorage();
+
+    // Build the list of new configuration to create.
+    $list = array_diff($storage->listAll(), $this->getActiveStorages()->listAll());
+
+    // If there is nothing to do.
+    if (empty($list)) {
+      return;
+    }
+
+    $config_to_create = $storage->readMultiple($list);
+
+    // Sort $config_to_create in the order of the least dependent first.
+    $dependency_manager = new ConfigDependencyManager();
+    $dependency_manager->setData($config_to_create);
+    $config_to_create = array_merge(array_flip($dependency_manager->sortAll()), $config_to_create);
+
+    // Create the optional configuration if there is any left after filtering.
+    if (!empty($config_to_create)) {
+      $this->createConfiguration(StorageInterface::DEFAULT_COLLECTION, $config_to_create);
+    }
+
+    // Validation during the installer is hard. For example:
+    // Drupal\ckeditor5\Plugin\Validation\Constraint\EnabledConfigurablePluginsConstraintValidator
+    // ends up calling _ckeditor5_theme_css() via
+    // Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition->validateDrupalAspects()
+    // and this expects the theme system to be set up correctly but we're in the
+    // installer so this cannot happen.
+    // @todo https://www.drupal.org/i/3443603 consider adding a validation step
+    //   for recipes to the installer via install_tasks().
+    if (InstallerKernel::installationAttempted()) {
+      return;
+    }
+
+    foreach (array_keys($config_to_create) as $name) {
+      // All config objects are mappings.
+      /** @var \Drupal\Core\Config\Schema\Mapping $typed_config */
+      $typed_config = $this->typedConfig->createFromNameAndData($name, $this->configFactory->get($name)->getRawData());
+      foreach ($typed_config->getConstraints() as $constraint) {
+        // Only validate the config if it has explicitly been marked as being
+        // validatable.
+        if ($constraint instanceof FullyValidatableConstraint) {
+          /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */
+          $violations = $typed_config->validate();
+          if (count($violations) > 0) {
+            throw new InvalidConfigException($violations, $typed_config);
+          }
+          break;
+        }
+      }
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php
new file mode 100644
index 0000000000000000000000000000000000000000..9af54bfcb733b8e0d472189eefb8814cddcffd40
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php
@@ -0,0 +1,162 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\NullStorage;
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * Merges two storages together.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeConfigStorageWrapper implements StorageInterface {
+
+  /**
+   * @param \Drupal\Core\Config\StorageInterface $storageA
+   *   First config storage to wrap.
+   * @param \Drupal\Core\Config\StorageInterface $storageB
+   *   Second config storage to wrap.
+   * @param string $collection
+   *   (optional) The collection to store configuration in. Defaults to the
+   *   default collection.
+   */
+  public function __construct(
+    protected readonly StorageInterface $storageA,
+    protected readonly StorageInterface $storageB,
+    protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION,
+  ) {
+  }
+
+  /**
+   * Creates a single config storage for an array of storages.
+   *
+   * If the same configuration is contained in multiple storages then the
+   * version returned is from the first storage supplied in the $storages array.
+   *
+   * @param \Drupal\Core\Config\StorageInterface[] $storages
+   *   An array of storages to merge into a single storage.
+   *
+   * @return \Drupal\Core\Config\StorageInterface
+   *   A config storage that represents a merge of all the provided storages.
+   */
+  public static function createStorageFromArray(array $storages): StorageInterface {
+    // If storages is empty use the NullStorage to represent an empty storage.
+    if (empty($storages)) {
+      return new NullStorage();
+    }
+
+    // When there is only one storage there is no point wrapping it.
+    if (count($storages) === 1) {
+      return reset($storages);
+    }
+
+    // Reduce all the storages to a single RecipeConfigStorageWrapper object.
+    // The storages are prioritized in the order they are added to $storages.
+    return array_reduce($storages, fn(StorageInterface $carry, StorageInterface $storage) => new static($carry, $storage), new static(
+      array_shift($storages),
+      array_shift($storages)
+    ));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exists($name): bool {
+    return $this->storageA->exists($name) || $this->storageB->exists($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function read($name): array|bool {
+    return $this->storageA->read($name) ?: $this->storageB->read($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function readMultiple(array $names): array {
+    // If both storageA and storageB contain the same configuration, the value
+    // for storageA takes precedence.
+    return array_merge($this->storageB->readMultiple($names), $this->storageA->readMultiple($names));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function write($name, array $data): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function delete($name): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rename($name, $new_name): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function encode($data): string {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function decode($raw): array {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function listAll($prefix = ''): array {
+    return array_unique(array_merge($this->storageA->listAll($prefix), $this->storageB->listAll($prefix)));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteAll($prefix = ''): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createCollection($collection): static {
+    return new static(
+      $this->storageA->createCollection($collection),
+      $this->storageB->createCollection($collection),
+      $collection
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAllCollectionNames(): array {
+    return array_unique(array_merge($this->storageA->getAllCollectionNames(), $this->storageB->getAllCollectionNames()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCollectionName(): string {
+    return $this->collection;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php
new file mode 100644
index 0000000000000000000000000000000000000000..927bcfc17fffa80c4971aced46a17dd18bceeb0b
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeConfigurator {
+
+  public readonly array $recipes;
+
+  /**
+   * @param string[] $recipes
+   *   A list of recipes for a recipe to apply. The recipes will be applied in
+   *   the order listed.
+   * @param \Drupal\Core\Recipe\RecipeDiscovery $recipeDiscovery
+   *   Recipe discovery.
+   */
+  public function __construct(array $recipes, RecipeDiscovery $recipeDiscovery) {
+    $this->recipes = array_map([$recipeDiscovery, 'getRecipe'], $recipes);
+  }
+
+  /**
+   * Returns all the recipes installed by this recipe.
+   *
+   * @return \Drupal\Core\Recipe\Recipe[]
+   *   An array of all the recipes being installed.
+   */
+  private function listAllRecipes(): array {
+    $recipes = [];
+    foreach ($this->recipes as $recipe) {
+      $recipes[] = $recipe;
+      $recipes = array_merge($recipes, $recipe->recipes->listAllRecipes());
+    }
+    return array_values(array_unique($recipes, SORT_REGULAR));
+  }
+
+  /**
+   * List all the extensions installed by this recipe and its dependencies.
+   *
+   * @return string[]
+   *   All the modules and themes that will be installed by the current
+   *   recipe and all the recipes it depends on.
+   */
+  public function listAllExtensions(): array {
+    $extensions = [];
+    foreach ($this->listAllRecipes() as $recipe) {
+      $extensions = array_merge($extensions, $recipe->install->modules, $recipe->install->themes);
+    }
+    return array_values(array_unique($extensions));
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php
new file mode 100644
index 0000000000000000000000000000000000000000..e27bcee390124008642479d70d04be2fd262a36b
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeDiscovery {
+
+  /**
+   * Constructs a recipe discovery object.
+   *
+   * @param string $path
+   *   The path will be searched folders containing a recipe.yml. There will be
+   *   no traversal further into the directory structure.
+   */
+  public function __construct(protected string $path) {
+  }
+
+  /**
+   * Gets a recipe object.
+   *
+   * @param string $name
+   *   The machine name of the recipe to find.
+   *
+   * @return \Drupal\Core\Recipe\Recipe
+   *   The recipe object.
+   *
+   * @throws \Drupal\Core\Recipe\UnknownRecipeException
+   *   Thrown when the recipe cannot be found.
+   */
+  public function getRecipe(string $name): Recipe {
+    // In order to allow recipes to include core provided recipes, $name can be
+    // a Drupal root relative path to a recipe folder. For example, a recipe can
+    // include the core provided 'article_tags' recipe by listing the recipe as
+    // 'core/recipes/article_tags'. It is strongly recommended not to rely on
+    // relative paths for including recipes. Required recipes should be put in
+    // the same parent directory as the recipe being applied. Note, only linux
+    // style directory separators are supported. PHP on Windows can resolve the
+    // mix of directory separators.
+    if (str_contains($name, '/')) {
+      $path = \Drupal::root() . "/$name/recipe.yml";
+    }
+    else {
+      $path = $this->path . "/$name/recipe.yml";
+    }
+
+    if (file_exists($path)) {
+      return Recipe::createFromDirectory(dirname($path));
+    }
+    $search_path = dirname($path, 2);
+    throw new UnknownRecipeException($name, $search_path, sprintf("Can not find the %s recipe, search path: %s", $name, $search_path));
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php
new file mode 100644
index 0000000000000000000000000000000000000000..604b566d76550aab1d9c4c3b3cedd02e2c0f0a62
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php
@@ -0,0 +1,146 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\FileStorage;
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * Allows the recipe to select configuration from the module.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeExtensionConfigStorage implements StorageInterface {
+
+  protected readonly StorageInterface $storage;
+
+  /**
+   * @param string $extensionPath
+   *   The path extension to read configuration from
+   * @param array $configNames
+   *   The list of config to read from the extension. An empty array means all
+   *   configuration.
+   * @param string $collection
+   *   (optional) The collection to store configuration in. Defaults to the
+   *   default collection.
+   */
+  public function __construct(protected readonly string $extensionPath, protected readonly array $configNames, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) {
+    $this->storage = new RecipeConfigStorageWrapper(
+      new FileStorage($this->extensionPath . '/config/install', $this->collection),
+      new FileStorage($this->extensionPath . '/config/optional', $this->collection),
+      $collection
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exists($name): bool {
+    if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) {
+      return FALSE;
+    }
+    return $this->storage->exists($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function read($name): array|bool {
+    if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) {
+      return FALSE;
+    }
+    return $this->storage->read($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function readMultiple(array $names): array {
+    if (!empty($this->configNames)) {
+      $names = array_intersect($this->configNames, $names);
+    }
+    return $this->storage->readMultiple($names);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function write($name, array $data): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function delete($name): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rename($name, $new_name): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function encode($data): string {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function decode($raw): array {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function listAll($prefix = ''): array {
+    $names = $this->storage->listAll($prefix);
+    if (!empty($this->configNames)) {
+      $names = array_intersect($this->configNames, $names);
+    }
+    return $names;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteAll($prefix = ''): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createCollection($collection): static {
+    return new static(
+      $this->extensionPath,
+      $this->configNames,
+      $collection
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAllCollectionNames(): array {
+    return $this->storage->getAllCollectionNames();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCollectionName(): string {
+    return $this->collection;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeFileException.php b/core/lib/Drupal/Core/Recipe/RecipeFileException.php
new file mode 100644
index 0000000000000000000000000000000000000000..d45cc7ba494e5ed0b680555eb327c59a890f160e
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeFileException.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Symfony\Component\Validator\ConstraintViolationList;
+
+/**
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeFileException extends \RuntimeException {
+
+  /**
+   * Constructs a RecipeFileException object.
+   *
+   * @param string $path
+   *   The path of the offending recipe file.
+   * @param string $message
+   *   (optional) The exception message.
+   * @param \Symfony\Component\Validator\ConstraintViolationList|null $violations
+   *   (optional) A list of validation constraint violations in the recipe file,
+   *   if any.
+   * @param int $code
+   *   (optional) The exception code.
+   * @param \Throwable|null $previous
+   *   (optional) The previous exception, if any.
+   */
+  public function __construct(
+    public readonly string $path,
+    string $message = '',
+    public readonly ?ConstraintViolationList $violations = NULL,
+    int $code = 0,
+    \Throwable $previous = NULL,
+  ) {
+    parent::__construct($message, $code, $previous);
+  }
+
+  /**
+   * Creates an instance of this exception from a set of validation errors.
+   *
+   * @param string $path
+   *   The path of the offending recipe file.
+   * @param \Symfony\Component\Validator\ConstraintViolationList $violations
+   *   The list of validation constraint violations.
+   *
+   * @return static
+   */
+  public static function fromViolationList(string $path, ConstraintViolationList $violations): static {
+    $lines = ["Validation errors were found in $path:"];
+
+    foreach ($violations as $violation) {
+      $lines[] = sprintf('- %s: %s', $violation->getPropertyPath(), $violation->getMessage());
+    }
+    return new static($path, implode("\n", $lines), $violations);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php
new file mode 100644
index 0000000000000000000000000000000000000000..cdc2102196365b4bb6e8e6bbbfe299052c24b84e
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Component\Assertion\Inspector;
+
+/**
+ * Exception thrown when recipes contain or depend on missing extensions.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeMissingExtensionsException extends \RuntimeException {
+
+  /**
+   * Constructs a RecipeMissingExtensionsException.
+   *
+   * @param array $extensions
+   *   The list of missing extensions.
+   * @param string $message
+   *   [optional] The Exception message to throw.
+   * @param int $code
+   *   [optional] The Exception code.
+   * @param null|\Throwable $previous
+   *   [optional] The previous throwable used for the exception chaining.
+   */
+  public function __construct(public readonly array $extensions, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
+    assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.');
+    if (!$message) {
+      $sorted = $this->extensions;
+      sort($sorted);
+      $message = sprintf("The following extensions are missing and are required for this recipe: %s", implode(", ", $sorted));
+    }
+    parent::__construct($message, $code, $previous);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php
new file mode 100644
index 0000000000000000000000000000000000000000..da5f3d713f19071ffe150b16184ddfec9fe1a8dc
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php
@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\StorageInterface;
+
+/**
+ * Wraps a config storage to allow recipe provided configuration to override it.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class RecipeOverrideConfigStorage implements StorageInterface {
+
+  /**
+   * @param \Drupal\Core\Config\StorageInterface $recipeStorage
+   *   The recipe's configuration storage.
+   * @param \Drupal\Core\Config\StorageInterface $wrappedStorage
+   *   The storage to override.
+   * @param string $collection
+   *   (optional) The collection to store configuration in. Defaults to the
+   *   default collection.
+   */
+  public function __construct(
+    protected readonly StorageInterface $recipeStorage,
+    protected readonly StorageInterface $wrappedStorage,
+    protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION,
+  ) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exists($name): bool {
+    return $this->wrappedStorage->exists($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function read($name): array|bool {
+    if ($this->wrappedStorage->exists($name) && $this->recipeStorage->exists($name)) {
+      return $this->recipeStorage->read($name);
+    }
+    return $this->wrappedStorage->read($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function readMultiple(array $names): array {
+    $data = $this->wrappedStorage->readMultiple($names);
+    foreach ($data as $name => $value) {
+      if ($this->recipeStorage->exists($name)) {
+        $data[$name] = $this->recipeStorage->read($name);
+      }
+    }
+    return $data;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function write($name, array $data): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function delete($name): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rename($name, $new_name): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function encode($data): string {
+    return $this->wrappedStorage->encode($data);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function decode($raw): array {
+    return $this->wrappedStorage->decode($raw);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function listAll($prefix = ''): array {
+    return $this->wrappedStorage->listAll($prefix);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteAll($prefix = ''): bool {
+    throw new \BadMethodCallException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createCollection($collection): static {
+    return new static(
+      $this->recipeStorage->createCollection($collection),
+      $this->wrappedStorage->createCollection($collection),
+      $collection
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAllCollectionNames(): array {
+    return $this->wrappedStorage->getAllCollectionNames();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCollectionName(): string {
+    return $this->collection;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php
new file mode 100644
index 0000000000000000000000000000000000000000..39e5d4e2c7273c5c3dbded28d7bf9790e533ecae
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+/**
+ * Exception thrown when a recipe has configuration that exists already.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+class RecipePreExistingConfigException extends \RuntimeException {
+
+  /**
+   * Constructs a RecipePreExistingConfigException.
+   *
+   * @param string $configName
+   *   The configuration name that has missing dependencies.
+   * @param string $message
+   *   [optional] The Exception message to throw.
+   * @param int $code
+   *   [optional] The Exception code.
+   * @param null|\Throwable $previous
+   *   [optional] The previous throwable used for the exception chaining.
+   */
+  public function __construct(public readonly string $configName, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
+    parent::__construct($message, $code, $previous);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/RecipeRunner.php b/core/lib/Drupal/Core/Recipe/RecipeRunner.php
new file mode 100644
index 0000000000000000000000000000000000000000..626c203d0371f00142283f6d037039908fcc3855
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/RecipeRunner.php
@@ -0,0 +1,319 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+use Drupal\Core\Config\FileStorage;
+use Drupal\Core\Config\InstallStorage;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\DefaultContent\Existing;
+use Drupal\Core\DefaultContent\Importer;
+use Drupal\Core\DefaultContent\Finder;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Applies a recipe.
+ *
+ * This class is currently static and use \Drupal::service() in order to put off
+ * having to solve issues caused by container rebuilds during module install and
+ * configuration import.
+ *
+ * @internal
+ *   This API is experimental.
+ *
+ * @todo https://www.drupal.org/i/3439717 Determine if there is a better to
+ *   inject and re-inject services.
+ */
+final class RecipeRunner {
+
+  /**
+   * @param \Drupal\Core\Recipe\Recipe $recipe
+   *   The recipe to apply.
+   */
+  public static function processRecipe(Recipe $recipe): void {
+    static::processRecipes($recipe->recipes);
+    static::processInstall($recipe->install, $recipe->config->getConfigStorage());
+    static::processConfiguration($recipe->config);
+    static::processContent($recipe->content);
+    static::triggerEvent($recipe);
+  }
+
+  /**
+   * Triggers the RecipeAppliedEvent.
+   *
+   * @param \Drupal\Core\Recipe\Recipe $recipe
+   *   The recipe to apply.
+   * @param array<mixed>|null $context
+   *   The batch context if called by a batch.
+   */
+  public static function triggerEvent(Recipe $recipe, ?array &$context = NULL): void {
+    $event = new RecipeAppliedEvent($recipe);
+    \Drupal::service(EventDispatcherInterface::class)->dispatch($event);
+    $context['message'] = t('Applied %recipe recipe.', ['%recipe' => $recipe->name]);
+    $context['results']['recipe'][] = $recipe->name;
+  }
+
+  /**
+   * Applies any recipes listed by the recipe.
+   *
+   * @param \Drupal\Core\Recipe\RecipeConfigurator $recipes
+   *   The list of recipes to apply.
+   */
+  protected static function processRecipes(RecipeConfigurator $recipes): void {
+    foreach ($recipes->recipes as $recipe) {
+      static::processRecipe($recipe);
+    }
+  }
+
+  /**
+   * Installs the extensions.
+   *
+   * @param \Drupal\Core\Recipe\InstallConfigurator $install
+   *   The list of extensions to install.
+   * @param \Drupal\Core\Config\StorageInterface $recipeConfigStorage
+   *   The recipe's configuration storage. Used to override extension provided
+   *   configuration.
+   */
+  protected static function processInstall(InstallConfigurator $install, StorageInterface $recipeConfigStorage): void {
+    foreach ($install->modules as $name) {
+      static::installModule($name, $recipeConfigStorage);
+    }
+
+    // Themes can depend on modules so have to be installed after modules.
+    foreach ($install->themes as $name) {
+      static::installTheme($name, $recipeConfigStorage);
+    }
+  }
+
+  /**
+   * Creates configuration and applies configuration actions.
+   *
+   * @param \Drupal\Core\Recipe\ConfigConfigurator $config
+   *   The config configurator from the recipe.
+   */
+  protected static function processConfiguration(ConfigConfigurator $config): void {
+    $config_installer = new RecipeConfigInstaller(
+      \Drupal::service('config.factory'),
+      \Drupal::service('config.storage'),
+      \Drupal::service('config.typed'),
+      \Drupal::service('config.manager'),
+      \Drupal::service('event_dispatcher'),
+      NULL,
+      \Drupal::service('extension.path.resolver'));
+
+    // Create configuration that is either supplied by the recipe or listed in
+    // the config.import section that does not exist.
+    $config_installer->installRecipeConfig($config);
+
+    if (!empty($config->config['actions'])) {
+      // Process the actions.
+      /** @var \Drupal\Core\Config\Action\ConfigActionManager $config_action_manager */
+      $config_action_manager = \Drupal::service('plugin.manager.config_action');
+      foreach ($config->config['actions'] as $config_name => $actions) {
+        foreach ($actions as $action_id => $data) {
+          $config_action_manager->applyAction($action_id, $config_name, $data);
+        }
+      }
+    }
+  }
+
+  /**
+   * Creates content contained in a recipe.
+   *
+   * @param \Drupal\Core\DefaultContent\Finder $content
+   *   The content finder object for the recipe.
+   */
+  protected static function processContent(Finder $content): void {
+    /** @var \Drupal\Core\DefaultContent\Importer $importer */
+    $importer = \Drupal::service(Importer::class);
+    $importer->setLogger(\Drupal::logger('recipe'));
+    $importer->importContent($content, Existing::Skip);
+  }
+
+  /**
+   * Converts a recipe into a series of batch operations.
+   *
+   * @param \Drupal\Core\Recipe\Recipe $recipe
+   *   The recipe to convert to batch operations.
+   *
+   * @return array<int, array{0: callable, 1: array{mixed}}>
+   *   The array of batch operations. Each value is an array with two values.
+   *   The first value is a callable and the second value are the arguments to
+   *   pass to the callable.
+   *
+   * @see \Drupal\Core\Batch\BatchBuilder::addOperation()
+   */
+  public static function toBatchOperations(Recipe $recipe): array {
+    $modules = [];
+    $themes = [];
+    $recipes = [];
+    return static::toBatchOperationsRecipe($recipe, $recipes, $modules, $themes);
+  }
+
+  /**
+   * Helper method to convert a recipe to batch operations.
+   *
+   * @param \Drupal\Core\Recipe\Recipe $recipe
+   *   The recipe to convert to batch operations.
+   * @param string[] $recipes
+   *   The paths of the recipes that have already been converted to batch operations.
+   * @param string[] $modules
+   *   The modules that will already be installed due to previous recipes in the
+   *   batch.
+   * @param string[] $themes
+   *   The themes that will already be installed due to previous recipes in the
+   *   batch.
+   *
+   * @return array<int, array{0: callable, 1: array{mixed}}>
+   *   The array of batch operations. Each value is an array with two values.
+   *   The first value is a callable and the second value are the arguments to
+   *   pass to the callable.
+   */
+  protected static function toBatchOperationsRecipe(Recipe $recipe, array $recipes, array &$modules, array &$themes): array {
+    if (in_array($recipe->path, $recipes, TRUE)) {
+      return [];
+    }
+    $steps = [];
+    $recipes[] = $recipe->path;
+
+    foreach ($recipe->recipes->recipes as $sub_recipe) {
+      $steps = array_merge($steps, static::toBatchOperationsRecipe($sub_recipe, $recipes, $modules, $themes));
+    }
+    $steps = array_merge($steps, static::toBatchOperationsInstall($recipe, $modules, $themes));
+    if ($recipe->config->hasTasks()) {
+      $steps[] = [[RecipeRunner::class, 'installConfig'], [$recipe]];
+    }
+    if (!empty($recipe->content->data)) {
+      $steps[] = [[RecipeRunner::class, 'installContent'], [$recipe]];
+    }
+    $steps[] = [[RecipeRunner::class, 'triggerEvent'], [$recipe]];
+
+    return $steps;
+  }
+
+  /**
+   * Converts a recipe's install tasks to batch operations.
+   *
+   * @param \Drupal\Core\Recipe\Recipe $recipe
+   *   The recipe to convert install tasks to batch operations.
+   * @param string[] $modules
+   *   The modules that will already be installed due to previous recipes in the
+   *   batch.
+   * @param string[] $themes
+   *   The themes that will already be installed due to previous recipes in the
+   *   batch.
+   *
+   * @return array<int, array{0: callable, 1: array{mixed}}>
+   *   The array of batch operations. Each value is an array with two values.
+   *   The first value is a callable and the second value are the arguments to
+   *   pass to the callable.
+   */
+  protected static function toBatchOperationsInstall(Recipe $recipe, array &$modules, array &$themes): array {
+    foreach ($recipe->install->modules as $name) {
+      if (in_array($name, $modules, TRUE)) {
+        continue;
+      }
+      $modules[] = $name;
+      $steps[] = [[RecipeRunner::class, 'installModule'], [$name, $recipe]];
+    }
+    foreach ($recipe->install->themes as $name) {
+      if (in_array($name, $themes, TRUE)) {
+        continue;
+      }
+      $themes[] = $name;
+      $steps[] = [[RecipeRunner::class, 'installTheme'], [$name, $recipe]];
+    }
+    return $steps ?? [];
+  }
+
+  /**
+   * Installs a module for a recipe.
+   *
+   * @param string $module
+   *   The name of the module to install.
+   * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage
+   *   The recipe or recipe's config storage.
+   * @param array<mixed>|null $context
+   *   The batch context if called by a batch.
+   */
+  public static function installModule(string $module, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void {
+    if ($recipeConfigStorage instanceof Recipe) {
+      $recipeConfigStorage = $recipeConfigStorage->config->getConfigStorage();
+    }
+    // Disable configuration entity install but use the config directory from
+    // the module.
+    \Drupal::service('config.installer')->setSyncing(TRUE);
+    $default_install_path = \Drupal::service('extension.list.module')->get($module)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
+    // Allow the recipe to override simple configuration from the module.
+    $storage = new RecipeOverrideConfigStorage(
+      $recipeConfigStorage,
+      new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION)
+    );
+    \Drupal::service('config.installer')->setSourceStorage($storage);
+
+    \Drupal::service('module_installer')->install([$module]);
+    \Drupal::service('config.installer')->setSyncing(FALSE);
+    $context['message'] = t('Installed %module module.', ['%module' => \Drupal::service('extension.list.module')->getName($module)]);
+    $context['results']['module'][] = $module;
+  }
+
+  /**
+   * Installs a theme for a recipe.
+   *
+   * @param string $theme
+   *   The name of the theme to install.
+   * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage
+   *   The recipe or recipe's config storage.
+   * @param array<mixed>|null $context
+   *   The batch context if called by a batch.
+   */
+  public static function installTheme(string $theme, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void {
+    if ($recipeConfigStorage instanceof Recipe) {
+      $recipeConfigStorage = $recipeConfigStorage->config->getConfigStorage();
+    }
+    // Disable configuration entity install.
+    \Drupal::service('config.installer')->setSyncing(TRUE);
+    $default_install_path = \Drupal::service('extension.list.theme')->get($theme)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
+    // Allow the recipe to override simple configuration from the theme.
+    $storage = new RecipeOverrideConfigStorage(
+      $recipeConfigStorage,
+      new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION)
+    );
+    \Drupal::service('config.installer')->setSourceStorage($storage);
+
+    \Drupal::service('theme_installer')->install([$theme]);
+    \Drupal::service('config.installer')->setSyncing(FALSE);
+    $context['message'] = t('Installed %theme theme.', ['%theme' => \Drupal::service('extension.list.theme')->getName($theme)]);
+    $context['results']['theme'][] = $theme;
+  }
+
+  /**
+   * Installs a config for a recipe.
+   *
+   * @param \Drupal\Core\Recipe\Recipe $recipe
+   *   The recipe to install config for.
+   * @param array<mixed>|null $context
+   *   The batch context if called by a batch.
+   */
+  public static function installConfig(Recipe $recipe, ?array &$context = NULL): void {
+    static::processConfiguration($recipe->config);
+    $context['message'] = t('Installed configuration for %recipe recipe.', ['%recipe' => $recipe->name]);
+    $context['results']['config'][] = $recipe->name;
+  }
+
+  /**
+   * Installs a content for a recipe.
+   *
+   * @param \Drupal\Core\Recipe\Recipe $recipe
+   *   The recipe to install content for.
+   * @param array<mixed>|null $context
+   *   The batch context if called by a batch.
+   */
+  public static function installContent(Recipe $recipe, ?array &$context = NULL): void {
+    static::processContent($recipe->content);
+    $context['message'] = t('Created content for %recipe recipe.', ['%recipe' => $recipe->name]);
+    $context['results']['content'][] = $recipe->name;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php
new file mode 100644
index 0000000000000000000000000000000000000000..b0f63c002824d9c2eea400e0cdeca7643c1b5789
--- /dev/null
+++ b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Recipe;
+
+/**
+ * Exception thrown when recipe is can not be found.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class UnknownRecipeException extends \RuntimeException {
+
+  /**
+   * @param string $recipe
+   *   The recipe's name.
+   * @param string $searchPath
+   *   The path searched for the recipe.
+   * @param string $message
+   *   (optional) The exception message.
+   * @param int $code
+   *   (optional) The exception code.
+   * @param \Throwable|null $previous
+   *   (optional) The previous exception.
+   */
+  public function __construct(public readonly string $recipe, public readonly string $searchPath, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
+    parent::__construct($message, $code, $previous);
+  }
+
+}
diff --git a/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php b/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php
new file mode 100644
index 0000000000000000000000000000000000000000..f239e226bc2654792a88c8f9d0be37e284030659
--- /dev/null
+++ b/core/modules/ckeditor5/src/Plugin/ConfigAction/AddItemToToolbar.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\ckeditor5\Plugin\ConfigAction;
+
+use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\editor\EditorInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+#[ConfigAction(
+  id: 'editor:addItemToToolbar',
+  admin_label: new TranslatableMarkup('Add an item to a CKEditor 5 toolbar'),
+  entity_types: ['editor'],
+)]
+final class AddItemToToolbar implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+  public function __construct(
+    private readonly ConfigManagerInterface $configManager,
+    private readonly CKEditor5PluginManagerInterface $pluginManager,
+    private readonly string $pluginId,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $container->get(ConfigManagerInterface::class),
+      $container->get(CKEditor5PluginManagerInterface::class),
+      $plugin_id,
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    $editor = $this->configManager->loadConfigEntityByName($configName);
+    assert($editor instanceof EditorInterface);
+
+    if ($editor->getEditor() !== 'ckeditor5') {
+      throw new ConfigActionException(sprintf('The %s config action only works with editors that use CKEditor 5.', $this->pluginId));
+    }
+
+    $editor_settings = $editor->getSettings();
+    if (is_string($value)) {
+      $editor_settings['toolbar']['items'][] = $item_name = $value;
+    }
+    else {
+      assert(is_array($value));
+
+      $item_name = $value['item_name'];
+      assert(is_string($item_name));
+
+      $replace = $value['replace'] ?? FALSE;
+      assert(is_bool($replace));
+
+      $position = $value['position'] ?? NULL;
+      if (is_int($position)) {
+        // If we want to replace the item at this position, then `replace`
+        // should be true. This would be useful if, for example, we wanted to
+        // replace the Image button with the Media Library.
+        array_splice($editor_settings['toolbar']['items'], $position, $replace ? 1 : 0, $item_name);
+      }
+      else {
+        $editor_settings['toolbar']['items'][] = $item_name;
+      }
+    }
+    // If we're just adding a vertical separator, there's nothing else we need
+    // to do at this point.
+    if ($item_name === '|') {
+      return;
+    }
+
+    // If this item is associated with a plugin, ensure that it's configured
+    // at the editor level, if necessary.
+    /** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $definition */
+    foreach ($this->pluginManager->getDefinitions() as $id => $definition) {
+      if (array_key_exists($item_name, $definition->getToolbarItems())) {
+        // If plugin settings already exist, don't change them.
+        if (array_key_exists($id, $editor_settings['plugins'])) {
+          break;
+        }
+        elseif ($definition->isConfigurable()) {
+          /** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface $plugin */
+          $plugin = $this->pluginManager->getPlugin($id, NULL);
+          $editor_settings['plugins'][$id] = $plugin->defaultConfiguration();
+        }
+        // No need to examine any other plugins.
+        break;
+      }
+    }
+
+    $editor->setSettings($editor_settings)->save();
+  }
+
+}
diff --git a/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php b/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c4c8b8e953aee0be256395bebf131844d6e10672
--- /dev/null
+++ b/core/modules/ckeditor5/tests/src/Kernel/ConfigAction/AddItemToToolbarConfigActionTest.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\ckeditor5\Kernel\ConfigAction;
+
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Recipe\InvalidConfigException;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\editor\Entity\Editor;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\ckeditor5\Plugin\ConfigAction\AddItemToToolbar
+ * @group ckeditor5
+ * @group Recipe
+ */
+class AddItemToToolbarConfigActionTest extends KernelTestBase {
+
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'ckeditor5',
+    'editor',
+    'filter',
+    'filter_test',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $configSchemaCheckerExclusions = [
+    // This test must be allowed to save invalid config, we can confirm that
+    // any invalid stuff is validated by the config actions system.
+    'editor.editor.filter_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installConfig('filter_test');
+
+    $editor = Editor::create([
+      'editor' => 'ckeditor5',
+      'format' => 'filter_test',
+      'image_upload' => ['status' => FALSE],
+    ]);
+    $editor->save();
+
+    /** @var array{toolbar: array{items: array<int, string>}} $settings */
+    $settings = Editor::load('filter_test')?->getSettings();
+    $this->assertSame(['heading', 'bold', 'italic'], $settings['toolbar']['items']);
+  }
+
+  /**
+   * @param string|array<string, mixed> $action
+   *   The value to pass to the config action.
+   * @param string[] $expected_toolbar_items
+   *   The items which should be in the editor toolbar, in the expected order.
+   *
+   * @testWith ["sourceEditing", ["heading", "bold", "italic", "sourceEditing"]]
+   *   [{"item_name": "sourceEditing"}, ["heading", "bold", "italic", "sourceEditing"]]
+   *   [{"item_name": "sourceEditing", "position": 1}, ["heading", "sourceEditing", "bold", "italic"]]
+   *   [{"item_name": "sourceEditing", "position": 1, "replace": true}, ["heading", "sourceEditing", "italic"]]
+   */
+  public function testAddItemToToolbar(string|array $action, array $expected_toolbar_items): void {
+    $recipe = $this->createRecipe([
+      'name' => 'CKEditor 5 toolbar item test',
+      'config' => [
+        'actions' => [
+          'editor.editor.filter_test' => [
+            'addItemToToolbar' => $action,
+          ],
+        ],
+      ],
+    ]);
+    RecipeRunner::processRecipe($recipe);
+
+    /** @var array{toolbar: array{items: string[]}, plugins: array<string, array<mixed>>} $settings */
+    $settings = Editor::load('filter_test')?->getSettings();
+    $this->assertSame($expected_toolbar_items, $settings['toolbar']['items']);
+    // The plugin's default settings should have been added.
+    $this->assertSame([], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
+  }
+
+  public function testAddNonExistentItem(): void {
+    $recipe = $this->createRecipe([
+      'name' => 'Add an invalid toolbar item',
+      'config' => [
+        'actions' => [
+          'editor.editor.filter_test' => [
+            'addItemToToolbar' => 'bogus_item',
+          ],
+        ],
+      ],
+    ]);
+
+    $this->expectException(InvalidConfigException::class);
+    $this->expectExceptionMessage("There were validation errors in editor.editor.filter_test:\n- settings.toolbar.items.3: The provided toolbar item <em class=\"placeholder\">bogus_item</em> is not valid.");
+    RecipeRunner::processRecipe($recipe);
+  }
+
+  public function testActionRequiresCKEditor5(): void {
+    $this->enableModules(['editor_test']);
+    Editor::load('filter_test')?->setEditor('unicorn')->setSettings([])->save();
+
+    $recipe = <<<YAML
+name: Not a CKEditor
+config:
+  actions:
+    editor.editor.filter_test:
+      addItemToToolbar: strikethrough
+YAML;
+
+    $this->expectException(ConfigActionException::class);
+    $this->expectExceptionMessage('The editor:addItemToToolbar config action only works with editors that use CKEditor 5.');
+    RecipeRunner::processRecipe($this->createRecipe($recipe));
+  }
+
+}
diff --git a/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2f1dd51d4c677358813c1a0e50ccd0915c93e094
--- /dev/null
+++ b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml
@@ -0,0 +1,4 @@
+name: 'Config action duplicate test'
+type: module
+package: Testing
+version: VERSION
diff --git a/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php
new file mode 100644
index 0000000000000000000000000000000000000000..560b2c7241b60ae822146da5e053ba840bd46f02
--- /dev/null
+++ b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\config_action_duplicate_test\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+#[ConfigAction(
+  id: 'config_action_duplicate_test:config_test.dynamic:setProtectedProperty',
+  admin_label: new TranslatableMarkup('A duplicate config action'),
+  entity_types: ['config_test'],
+)]
+final class DuplicateConfigAction implements ConfigActionPluginInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    // This method should never be called.
+    throw new \BadMethodCallException();
+  }
+
+}
diff --git a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
index b0118f6821161dea15b8f573a70fc8f894c8aa5e..99b7cfd8f06485340e380d34c69ca1109b176dec 100644
--- a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
+++ b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
@@ -24,6 +24,9 @@ config_test_dynamic:
     protected_property:
       type: string
       label: 'Protected property'
+    array_property:
+      type: ignore
+      label: 'Array property'
 
 config_test.dynamic.*:
   type: config_test_dynamic
diff --git a/core/modules/config/tests/config_test/config_test.module b/core/modules/config/tests/config_test/config_test.module
index b9fac8f793bc13cb758d4bceb812daa915f4b3c9..98e50125141d5ff08f460245734a73771ccd5fcd 100644
--- a/core/modules/config/tests/config_test/config_test.module
+++ b/core/modules/config/tests/config_test/config_test.module
@@ -42,6 +42,10 @@ function config_test_entity_type_alter(array &$entity_types) {
   if (\Drupal::service('state')->get('config_test.lookup_keys', FALSE)) {
     $entity_types['config_test']->set('lookup_keys', ['uuid', 'style']);
   }
+
+  if (\Drupal::service('state')->get('config_test.class_override', FALSE)) {
+    $entity_types['config_test']->setClass(\Drupal::service('state')->get('config_test.class_override'));
+  }
 }
 
 /**
diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php
new file mode 100644
index 0000000000000000000000000000000000000000..b51e41af5d95dac9b5403cf24fde5701fef47328
--- /dev/null
+++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Drupal\config_test\ConfigActionErrorEntity;
+
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
+
+/**
+ * Test entity class.
+ */
+class DuplicatePluralizedMethodName extends ConfigTest {
+
+  #[ActionMethod(pluralize: 'testMethod')]
+  public function testMethod() {
+  }
+
+}
diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php
new file mode 100644
index 0000000000000000000000000000000000000000..98c5d07559aa698df029c93484a0f186496b06a3
--- /dev/null
+++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\config_test\ConfigActionErrorEntity;
+
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
+
+/**
+ * Test entity class.
+ */
+class DuplicatePluralizedOtherMethodName extends ConfigTest {
+
+  #[ActionMethod(pluralize: 'testMethod2')]
+  public function testMethod() {
+  }
+
+  #[ActionMethod()]
+  public function testMethod2() {
+  }
+
+}
diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
index 6b9e8007dbf22cc99d704d715b662369e1230a63..b461828a635e0095d1dcdb5f4fc9dc422937d78d 100644
--- a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
+++ b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
@@ -46,4 +46,13 @@ class ConfigQueryTest extends ConfigTest {
    */
   public $array = [];
 
+  /**
+   * {@inheritdoc}
+   */
+  public function concatProtectedProperty(string $value1, string $value2): static {
+    // This method intentionally does not have the config action attribute to
+    // ensure it is still discovered.
+    return parent::concatProtectedProperty($value1, $value2);
+  }
+
 }
diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
index 30a7a3526d0757c55d763faa88825dfb5be4bddb..e2597f0277d9680a0d5305adcee2c50f22bdf8e4 100644
--- a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
+++ b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
@@ -2,10 +2,12 @@
 
 namespace Drupal\config_test\Entity;
 
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
 use Drupal\config_test\ConfigTestInterface;
 use Drupal\Core\Config\Entity\ConfigEntityInterface;
 use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 
 /**
  * Defines the ConfigTest configuration entity.
@@ -36,6 +38,7 @@
  *     "size",
  *     "size_value",
  *     "protected_property",
+ *     "array_property",
  *   },
  *   links = {
  *     "edit-form" = "/admin/structure/config_test/manage/{config_test}",
@@ -83,6 +86,13 @@ class ConfigTest extends ConfigEntityBase implements ConfigTestInterface {
    */
   protected $protected_property;
 
+  /**
+   * An array property of the configuration entity.
+   *
+   * @var array
+   */
+  protected array $array_property = [];
+
   /**
    * {@inheritdoc}
    */
@@ -188,4 +198,142 @@ public function isInstallable() {
     return $this->id != 'isinstallable' || \Drupal::state()->get('config_test.isinstallable');
   }
 
+  /**
+   * Sets the protected property value.
+   *
+   * @param $value
+   *   The value to set.
+   *
+   * @return $this
+   *   The config entity.
+   */
+  #[ActionMethod(pluralize: FALSE)]
+  public function setProtectedProperty(string $value): static {
+    $this->protected_property = $value;
+    return $this;
+  }
+
+  /**
+   * Gets the protected property value.
+   *
+   * @return string
+   *   The protected property value.
+   */
+  public function getProtectedProperty(): string {
+    return $this->protected_property;
+  }
+
+  /**
+   * Concatenates the two params and sets the protected property value.
+   *
+   * @param $value1
+   *   The first value to concatenate.
+   * @param $value2
+   *   The second value to concatenate.
+   *
+   * @return $this
+   *   The config entity.
+   */
+  #[ActionMethod()]
+  public function concatProtectedProperty(string $value1, string $value2): static {
+    $this->protected_property = $value1 . $value2;
+    return $this;
+  }
+
+  /**
+   * Concatenates up to two params and sets the protected property value.
+   *
+   * @param $value1
+   *   The first value to concatenate.
+   * @param $value2
+   *   (optional) The second value to concatenate. Defaults to ''.
+   *
+   * @return $this
+   *   The config entity.
+   */
+  #[ActionMethod(pluralize: FALSE)]
+  public function concatProtectedPropertyOptional(string $value1, string $value2 = ''): static {
+    $this->protected_property = $value1 . $value2;
+    return $this;
+  }
+
+  /**
+   * Appends to protected property.
+   *
+   * @param $value
+   *   The value to append.
+   *
+   * @return $this
+   *   The config entity.
+   */
+  #[ActionMethod()]
+  public function append(string $value): static {
+    $this->protected_property .= $value;
+    return $this;
+  }
+
+  /**
+   * Sets the protected property to a default value.
+   *
+   * @return $this
+   *   The config entity.
+   */
+  #[ActionMethod(pluralize: FALSE, adminLabel: new TranslatableMarkup('Set default name'))]
+  public function defaultProtectedProperty(): static {
+    $this->protected_property = 'Set by method';
+    return $this;
+  }
+
+  /**
+   * Adds a value to the array property.
+   *
+   * @param mixed $value
+   *   The value to add.
+   *
+   * @return $this
+   *   The config entity.
+   */
+  #[ActionMethod(pluralize: 'addToArrayMultipleTimes')]
+  public function addToArray(mixed $value): static {
+    $this->array_property[] = $value;
+    return $this;
+  }
+
+  /**
+   * Gets the array property value.
+   *
+   * @return array
+   *   The array property value.
+   */
+  public function getArrayProperty(): array {
+    return $this->array_property;
+  }
+
+  /**
+   * Sets the array property.
+   *
+   * @param $value
+   *   The value to set.
+   *
+   * @return $this
+   *   The config entity.
+   */
+  #[ActionMethod(pluralize: FALSE)]
+  public function setArray(array $value): static {
+    $this->array_property = $value;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function toArray() {
+    $properties = parent::toArray();
+    // Only export the 'array_property' is there is data.
+    if (empty($properties['array_property'])) {
+      unset($properties['array_property']);
+    }
+    return $properties;
+  }
+
 }
diff --git a/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php
new file mode 100644
index 0000000000000000000000000000000000000000..a2df177036ba003bf9c1c51c3e96132c2c5817f4
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModeration.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\ConfigAction;
+
+use Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\workflows\WorkflowInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+#[ConfigAction(
+  id: 'add_moderation',
+  entity_types: ['workflow'],
+  deriver: AddModerationDeriver::class,
+)]
+final class AddModeration implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+  public function __construct(
+    private readonly ConfigManagerInterface $configManager,
+    private readonly EntityTypeManagerInterface $entityTypeManager,
+    private readonly string $pluginId,
+    private readonly string $targetEntityType,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    assert(is_array($plugin_definition));
+    $target_entity_type = $plugin_definition['target_entity_type'];
+
+    return new static(
+      $container->get(ConfigManagerInterface::class),
+      $container->get(EntityTypeManagerInterface::class),
+      $plugin_id,
+      $target_entity_type,
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    $workflow = $this->configManager->loadConfigEntityByName($configName);
+    assert($workflow instanceof WorkflowInterface);
+
+    $plugin = $workflow->getTypePlugin();
+    if (!$plugin instanceof ContentModerationInterface) {
+      throw new ConfigActionException("The $this->pluginId config action only works with Content Moderation workflows.");
+    }
+
+    assert($value === '*' || is_array($value));
+    if ($value === '*') {
+      /** @var \Drupal\Core\Entity\EntityTypeInterface $definition */
+      $definition = $this->entityTypeManager->getDefinition($this->targetEntityType);
+      /** @var string $bundle_entity_type */
+      $bundle_entity_type = $definition->getBundleEntityType();
+
+      $value = $this->entityTypeManager->getStorage($bundle_entity_type)
+        ->getQuery()
+        ->accessCheck(FALSE)
+        ->execute();
+    }
+    foreach ($value as $bundle) {
+      $plugin->addEntityTypeAndBundle($this->targetEntityType, $bundle);
+    }
+    $workflow->save();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php
new file mode 100644
index 0000000000000000000000000000000000000000..fec8c773e66303f7bde3b41db91a8b92f725c205
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/ConfigAction/AddModerationDeriver.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\content_moderation\Plugin\ConfigAction;
+
+// cspell:ignore inflector
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\Container;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\String\Inflector\EnglishInflector;
+
+final class AddModerationDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+  use StringTranslationTrait;
+
+  public function __construct(
+    private readonly EntityTypeManagerInterface $entityTypeManager,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id): static {
+    return new static(
+      $container->get(EntityTypeManagerInterface::class),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    $inflector = new EnglishInflector();
+
+    foreach ($this->entityTypeManager->getDefinitions() as $id => $entity_type) {
+      if ($bundle_entity_type = $entity_type->getBundleEntityType()) {
+        /** @var \Drupal\Core\Entity\EntityTypeInterface $bundle_entity_type */
+        $bundle_entity_type = $this->entityTypeManager->getDefinition($bundle_entity_type);
+        // Convert unique plugin IDs, like `taxonomy_vocabulary`, into strings
+        // like `TaxonomyVocabulary`.
+        $suffix = Container::camelize($bundle_entity_type->id());
+        [$suffix] = $inflector->pluralize($suffix);
+        $this->derivatives["add{$suffix}"] = [
+          'target_entity_type' => $id,
+          'admin_label' => $this->t('Add moderation to all @bundles', [
+            '@bundles' => $bundle_entity_type->getPluralLabel() ?: $bundle_entity_type->id(),
+          ]),
+        ] + $base_plugin_definition;
+      }
+    }
+    return parent::getDerivativeDefinitions($base_plugin_definition);
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php b/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4d5afd395e2ab2bf133a13125d69c362f5987fb1
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ConfigAction/AddModerationConfigActionTest.php
@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\content_moderation\Kernel\ConfigAction;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
+use Drupal\workflows\Entity\Workflow;
+
+/**
+ * @covers \Drupal\content_moderation\Plugin\ConfigAction\AddModeration
+ * @covers \Drupal\content_moderation\Plugin\ConfigAction\AddModerationDeriver
+ * @group content_moderation
+ * @group Recipe
+ */
+class AddModerationConfigActionTest extends KernelTestBase {
+
+  use ContentTypeCreationTrait;
+  use RecipeTestTrait {
+    createRecipe as traitCreateRecipe;
+  }
+  use TaxonomyTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'field',
+    'node',
+    'system',
+    'taxonomy',
+    'text',
+    'user',
+  ];
+
+  public function testAddEntityTypeAndBundle(): void {
+    $this->installConfig('node');
+
+    $this->createContentType(['type' => 'a']);
+    $this->createContentType(['type' => 'b']);
+    $this->createContentType(['type' => 'c']);
+    $this->createVocabulary(['vid' => 'tags']);
+
+    $recipe = $this->createRecipe('workflows.workflow.editorial');
+    RecipeRunner::processRecipe($recipe);
+
+    /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface $plugin */
+    $plugin = Workflow::load('editorial')?->getTypePlugin();
+    $this->assertSame(['a', 'b'], $plugin->getBundlesForEntityType('node'));
+    $this->assertSame(['tags'], $plugin->getBundlesForEntityType('taxonomy_term'));
+  }
+
+  public function testWorkflowMustBeContentModeration(): void {
+    $this->enableModules(['workflows', 'workflow_type_test']);
+
+    $workflow = Workflow::create([
+      'id' => 'test',
+      'label' => 'Test workflow',
+      'type' => 'workflow_type_test',
+    ]);
+    $workflow->save();
+
+    $recipe = $this->createRecipe($workflow->getConfigDependencyName());
+    $this->expectException(ConfigActionException::class);
+    $this->expectExceptionMessage("The add_moderation:addNodeTypes config action only works with Content Moderation workflows.");
+    RecipeRunner::processRecipe($recipe);
+  }
+
+  public function testActionOnlyTargetsWorkflows(): void {
+    $recipe = $this->createRecipe('user.role.anonymous');
+    $this->expectException(PluginNotFoundException::class);
+    $this->expectExceptionMessage('The "addNodeTypes" plugin does not exist.');
+    RecipeRunner::processRecipe($recipe);
+  }
+
+  public function testDeriverAdminLabel(): void {
+    $this->enableModules(['workflows', 'content_moderation']);
+
+    /** @var array<string, array{admin_label: \Stringable}> $definitions */
+    $definitions = $this->container->get('plugin.manager.config_action')
+      ->getDefinitions();
+
+    $this->assertSame('Add moderation to all content types', (string) $definitions['add_moderation:addNodeTypes']['admin_label']);
+    $this->assertSame('Add moderation to all vocabularies', (string) $definitions['add_moderation:addTaxonomyVocabularies']['admin_label']);
+  }
+
+  private function createRecipe(string $config_name): Recipe {
+    $recipe = <<<YAML
+name: 'Add entity types and bundles to workflow'
+recipes:
+  - core/recipes/editorial_workflow
+config:
+  actions:
+    $config_name:
+      addNodeTypes:
+        - a
+        - b
+      addTaxonomyVocabularies: '*'
+YAML;
+    return $this->traitCreateRecipe($recipe);
+  }
+
+}
diff --git a/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php b/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php
new file mode 100644
index 0000000000000000000000000000000000000000..e1642a80bba8b2943d02c58b00f868e3c3477c2f
--- /dev/null
+++ b/core/modules/field/src/Plugin/ConfigAction/AddToAllBundles.php
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\field\Plugin\ConfigAction;
+
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\field\FieldStorageConfigInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Adds a field to all bundles of its target entity type.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+#[ConfigAction(
+  id: 'field_storage_config:addToAllBundles',
+  admin_label: new TranslatableMarkup('Add a field to all bundles'),
+  entity_types: ['field_storage_config'],
+)]
+final class AddToAllBundles implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+  public function __construct(
+    private readonly EntityTypeManagerInterface $entityTypeManager,
+    private readonly EntityTypeBundleInfoInterface $entityTypeBundleInfo,
+    private readonly ConfigManagerInterface $configManager,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    return new static(
+      $container->get(EntityTypeManagerInterface::class),
+      $container->get(EntityTypeBundleInfoInterface::class),
+      $container->get(ConfigManagerInterface::class),
+    );
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    assert(is_array($value));
+
+    $field_storage = $this->configManager->loadConfigEntityByName($configName);
+    assert($field_storage instanceof FieldStorageConfigInterface);
+
+    $storage = $this->entityTypeManager->getStorage('field_config');
+
+    $entity_type_id = $field_storage->getTargetEntityTypeId();
+    $field_name = $field_storage->getName();
+
+    $existing_fields = $storage->getQuery()
+      ->condition('entity_type', $entity_type_id)
+      ->condition('field_name', $field_name)
+      ->execute();
+
+    // Get all bundles of the target entity type.
+    $bundles = array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id));
+    foreach ($bundles as $bundle) {
+      $id = "$entity_type_id.$bundle.$field_name";
+      if (in_array($id, $existing_fields, TRUE)) {
+        if (empty($value['fail_if_exists'])) {
+          continue;
+        }
+        throw new ConfigActionException(sprintf('Field %s already exists.', $id));
+      }
+      $storage->create([
+        'label' => $value['label'],
+        'bundle' => $bundle,
+        'description' => $value['description'],
+        'field_storage' => $field_storage,
+      ])->save();
+    }
+  }
+
+}
diff --git a/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php b/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4f6ec730877deadad06456ec680cf5d246b9944b
--- /dev/null
+++ b/core/modules/field/tests/src/Kernel/AddToAllBundlesConfigActionTest.php
@@ -0,0 +1,148 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\field\Kernel;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @covers \Drupal\field\Plugin\ConfigAction\AddToAllBundles
+ *
+ * @group Recipe
+ * @group field
+ */
+class AddToAllBundlesConfigActionTest extends KernelTestBase {
+
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['field', 'node', 'system', 'text', 'user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    NodeType::create([
+      'type' => 'one',
+      'name' => 'One',
+    ])->save();
+    NodeType::create([
+      'type' => 'two',
+      'name' => 'Two',
+    ])->save();
+  }
+
+  /**
+   * Tests instantiating a field on all bundles of an entity type.
+   */
+  public function testInstantiateNewFieldOnAllBundles(): void {
+    // Ensure the body field doesn't actually exist yet.
+    $storage_definitions = $this->container->get(EntityFieldManagerInterface::class)
+      ->getFieldStorageDefinitions('node');
+    $this->assertArrayNotHasKey('body', $storage_definitions);
+
+    $this->applyAction('field.storage.node.body');
+
+    // Fields and expected data exist.
+    /** @var \Drupal\field\FieldConfigInterface[] $body_fields */
+    $body_fields = $this->container->get(EntityTypeManagerInterface::class)
+      ->getStorage('field_config')
+      ->loadByProperties([
+        'entity_type' => 'node',
+        'field_name' => 'body',
+      ]);
+    ksort($body_fields);
+    $this->assertSame(['node.one.body', 'node.two.body'], array_keys($body_fields));
+    foreach ($body_fields as $field) {
+      $this->assertSame('Body field label', $field->label());
+      $this->assertSame('Set by config actions.', $field->getDescription());
+    }
+
+    // Expect an error when the 'addToAllBundles' action is invoked on anything
+    // other than a field storage config entity.
+    $this->expectException(PluginNotFoundException::class);
+    $this->expectExceptionMessage('The "addToAllBundles" plugin does not exist.');
+    $this->applyAction('user.role.anonymous');
+  }
+
+  /**
+   * Tests that the action can be set to fail if the field already exists.
+   */
+  public function testFailIfExists(): void {
+    $this->installConfig('node');
+    node_add_body_field(NodeType::load('one'));
+
+    $this->expectException(ConfigActionException::class);
+    $this->expectExceptionMessage('Field node.one.body already exists.');
+    $this->applyAction('field.storage.node.body', TRUE);
+  }
+
+  /**
+   * Tests that the action will ignore existing fields by default.
+   */
+  public function testIgnoreExistingFields(): void {
+    $this->installConfig('node');
+
+    node_add_body_field(NodeType::load('one'))
+      ->setLabel('Original label')
+      ->setDescription('Original description')
+      ->save();
+
+    $this->applyAction('field.storage.node.body');
+
+    // The existing field should not be changed.
+    $field = FieldConfig::loadByName('node', 'one', 'body');
+    $this->assertInstanceOf(FieldConfig::class, $field);
+    $this->assertSame('Original label', $field->label());
+    $this->assertSame('Original description', $field->getDescription());
+
+    // But the new field should be created as expected.
+    $field = FieldConfig::loadByName('node', 'two', 'body');
+    $this->assertInstanceOf(FieldConfig::class, $field);
+    $this->assertSame('Body field label', $field->label());
+    $this->assertSame('Set by config actions.', $field->getDescription());
+  }
+
+  /**
+   * Applies a recipe with the addToAllBundles action.
+   *
+   * @param string $config_name
+   *   The name of the config object which should run the addToAllBundles
+   *   action.
+   * @param bool $fail_if_exists
+   *   (optional) Whether the action should fail if the field already exists on
+   *   any bundle. Defaults to FALSE.
+   */
+  private function applyAction(string $config_name, bool $fail_if_exists = FALSE): void {
+    $fail_if_exists = var_export($fail_if_exists, TRUE);
+    $contents = <<<YAML
+name: Instantiate field on all bundles
+config:
+  import:
+    node:
+      - field.storage.node.body
+  actions:
+    $config_name:
+      addToAllBundles:
+        label: Body field label
+        description: Set by config actions.
+        fail_if_exists: $fail_if_exists
+YAML;
+    $recipe = $this->createRecipe($contents);
+    RecipeRunner::processRecipe($recipe);
+  }
+
+}
diff --git a/core/modules/filter/src/Entity/FilterFormat.php b/core/modules/filter/src/Entity/FilterFormat.php
index 1742bcf1b43b5ebe299742c3a1fcf5b2922235b4..699b6a99d4a4b682e8b6b4d14e02bc4a4fcbdc38 100644
--- a/core/modules/filter/src/Entity/FilterFormat.php
+++ b/core/modules/filter/src/Entity/FilterFormat.php
@@ -3,9 +3,11 @@
 namespace Drupal\filter\Entity;
 
 use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
 use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
 use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\filter\FilterFormatInterface;
 use Drupal\filter\FilterPluginCollection;
 use Drupal\filter\Plugin\FilterInterface;
@@ -160,6 +162,7 @@ public function getPluginCollections() {
   /**
    * {@inheritdoc}
    */
+  #[ActionMethod(adminLabel: new TranslatableMarkup('Sets configuration for a filter plugin'))]
   public function setFilterConfig($instance_id, array $configuration) {
     $this->filters[$instance_id] = $configuration;
     if (isset($this->filterCollection)) {
diff --git a/core/modules/user/src/Entity/Role.php b/core/modules/user/src/Entity/Role.php
index 18194f3390eb4f5324fd44c455e82d453fd1712d..68ddd501f514f95cf7a7b46179fe7694dad3d918 100644
--- a/core/modules/user/src/Entity/Role.php
+++ b/core/modules/user/src/Entity/Role.php
@@ -2,8 +2,10 @@
 
 namespace Drupal\user\Entity;
 
+use Drupal\Core\Config\Action\Attribute\ActionMethod;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
 use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\user\RoleInterface;
 
 /**
@@ -126,6 +128,7 @@ public function hasPermission($permission) {
   /**
    * {@inheritdoc}
    */
+  #[ActionMethod(adminLabel: new TranslatableMarkup('Add permission to role'))]
   public function grantPermission($permission) {
     if ($this->isAdmin()) {
       return $this;
diff --git a/core/profiles/standard/tests/src/Functional/StandardTest.php b/core/profiles/standard/tests/src/Functional/StandardTest.php
index cbe8f38fc608f1f117c46718e8bcb82a93025fbd..4bb993ffc947108a03ef6ebe8021aa8b05d33d15 100644
--- a/core/profiles/standard/tests/src/Functional/StandardTest.php
+++ b/core/profiles/standard/tests/src/Functional/StandardTest.php
@@ -4,22 +4,8 @@
 
 namespace Drupal\Tests\standard\Functional;
 
-use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
-use Drupal\Component\Utility\Html;
-use Drupal\editor\Entity\Editor;
-use Drupal\image\Entity\ImageStyle;
-use Drupal\media\Entity\MediaType;
-use Drupal\media\Plugin\media\Source\Image;
-use Drupal\Tests\SchemaCheckTestTrait;
-use Drupal\contact\Entity\ContactForm;
-use Drupal\Core\Url;
-use Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber;
-use Drupal\filter\Entity\FilterFormat;
 use Drupal\Tests\BrowserTestBase;
-use Drupal\Tests\RequirementsPageTrait;
-use Drupal\user\Entity\Role;
-use Drupal\user\Entity\User;
-use Symfony\Component\Validator\ConstraintViolation;
+use Drupal\Tests\standard\Traits\StandardTestTrait;
 
 /**
  * Tests Standard installation profile expectations.
@@ -27,289 +13,8 @@
  * @group standard
  */
 class StandardTest extends BrowserTestBase {
-
-  use SchemaCheckTestTrait;
-  use RequirementsPageTrait;
+  use StandardTestTrait;
 
   protected $profile = 'standard';
 
-  /**
-   * The admin user.
-   *
-   * @var \Drupal\user\UserInterface
-   */
-  protected $adminUser;
-
-  /**
-   * Tests Standard installation profile.
-   */
-  public function testStandard() {
-    $this->drupalGet('');
-    $this->assertSession()->pageTextContains('Powered by Drupal');
-
-    // Test anonymous user can access 'Main navigation' block.
-    $this->adminUser = $this->drupalCreateUser([
-      'administer blocks',
-      'administer block content',
-      'post comments',
-      'skip comment approval',
-      'create article content',
-      'create page content',
-    ]);
-    $this->drupalLogin($this->adminUser);
-    // Configure the block.
-    $this->drupalGet('admin/structure/block/add/system_menu_block:main/olivero');
-    $this->submitForm([
-      'region' => 'sidebar',
-      'id' => 'main_navigation',
-    ], 'Save block');
-    // Verify admin user can see the block.
-    $this->drupalGet('');
-    $this->assertSession()->pageTextContains('Main navigation');
-
-    // Verify we have role = complementary on help_block blocks.
-    $this->drupalGet('admin/structure/block');
-    $this->assertSession()->elementAttributeContains('xpath', "//div[@id='block-olivero-help']", 'role', 'complementary');
-
-    // Verify anonymous user can see the block.
-    $this->drupalLogout();
-    $this->assertSession()->pageTextContains('Main navigation');
-
-    // Ensure comments don't show in the front page RSS feed.
-    // Create an article.
-    $this->drupalCreateNode([
-      'type' => 'article',
-      'title' => 'Foobar',
-      'promote' => 1,
-      'status' => 1,
-      'body' => [['value' => 'Then she picked out two somebodies,<br />Sally and me', 'format' => 'basic_html']],
-    ]);
-
-    // Add a comment.
-    $this->drupalLogin($this->adminUser);
-    $this->drupalGet('node/1');
-    // Verify that a line break is present.
-    $this->assertSession()->responseContains('Then she picked out two somebodies,<br>Sally and me');
-    $this->submitForm([
-      'subject[0][value]' => 'Bar foo',
-      'comment_body[0][value]' => 'Then she picked out two somebodies, Sally and me',
-    ], 'Save');
-    // Fetch the feed.
-    $this->drupalGet('rss.xml');
-    $this->assertSession()->responseContains('Foobar');
-    $this->assertSession()->responseNotContains('Then she picked out two somebodies, Sally and me');
-
-    // Ensure block body exists.
-    $this->drupalGet('block/add');
-    $this->assertSession()->fieldExists('body[0][value]');
-
-    // Now we have all configuration imported, test all of them for schema
-    // conformance. Ensures all imported default configuration is valid when
-    // standard profile modules are enabled.
-    $names = $this->container->get('config.storage')->listAll();
-    /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config */
-    $typed_config = $this->container->get('config.typed');
-    foreach ($names as $name) {
-      $config = $this->config($name);
-      $this->assertConfigSchema($typed_config, $name, $config->get());
-    }
-
-    // Validate all configuration.
-    // @todo Generalize in https://www.drupal.org/project/drupal/issues/2164373
-    foreach (Editor::loadMultiple() as $editor) {
-      // Currently only text editors using CKEditor 5 can be validated.
-      if ($editor->getEditor() !== 'ckeditor5') {
-        continue;
-      }
-
-      $this->assertSame([], array_map(
-        function (ConstraintViolation $v) {
-          return (string) $v->getMessage();
-        },
-        iterator_to_array(CKEditor5::validatePair(
-          $editor,
-          $editor->getFilterFormat()
-        ))
-      ));
-    }
-
-    // Ensure that configuration from the Standard profile is not reused when
-    // enabling a module again since it contains configuration that can not be
-    // installed. For example, editor.editor.basic_html is editor configuration
-    // that depends on the CKEditor 5 module. The CKEditor 5 module can not be
-    // installed before the editor module since it depends on the editor module.
-    // The installer does not have this limitation since it ensures that all of
-    // the install profiles dependencies are installed before creating the
-    // editor configuration.
-    foreach (FilterFormat::loadMultiple() as $filter) {
-      // Ensure that editor can be uninstalled by removing use in filter
-      // formats. It is necessary to prime the filter collection before removing
-      // the filter.
-      $filter->filters();
-      $filter->removeFilter('editor_file_reference');
-      $filter->save();
-    }
-    \Drupal::service('module_installer')->uninstall(['editor', 'ckeditor5']);
-    $this->rebuildContainer();
-    \Drupal::service('module_installer')->install(['editor']);
-    /** @var \Drupal\contact\ContactFormInterface $contact_form */
-    $contact_form = ContactForm::load('feedback');
-    $recipients = $contact_form->getRecipients();
-    $this->assertEquals(['simpletest@example.com'], $recipients);
-
-    $role = Role::create([
-      'id' => 'admin_theme',
-      'label' => 'Admin theme',
-    ]);
-    $role->grantPermission('view the administration theme');
-    $role->save();
-    $this->adminUser->addRole($role->id());
-    $this->adminUser->save();
-    $this->drupalGet('node/add');
-    $this->assertSession()->statusCodeEquals(200);
-
-    // Ensure that there are no pending updates after installation.
-    $this->drupalLogin($this->rootUser);
-    $this->drupalGet('update.php/selection');
-    $this->updateRequirementsProblem();
-    $this->drupalGet('update.php/selection');
-    $this->assertSession()->pageTextContains('No pending updates.');
-
-    // Ensure that there are no pending entity updates after installation.
-    $this->assertFalse($this->container->get('entity.definition_update_manager')->needsUpdates(), 'After installation, entity schema is up to date.');
-
-    // Make sure the optional image styles are not installed.
-    $this->drupalGet('admin/config/media/image-styles');
-    $this->assertSession()->pageTextNotContains('Max 325x325');
-    $this->assertSession()->pageTextNotContains('Max 650x650');
-    $this->assertSession()->pageTextNotContains('Max 1300x1300');
-    $this->assertSession()->pageTextNotContains('Max 2600x2600');
-
-    // Make sure the optional image styles are installed after enabling
-    // the responsive_image module.
-    \Drupal::service('module_installer')->install(['responsive_image']);
-    $this->rebuildContainer();
-    $this->drupalGet('admin/config/media/image-styles');
-    $this->assertSession()->pageTextContains('Max 325x325');
-    $this->assertSession()->pageTextContains('Max 650x650');
-    $this->assertSession()->pageTextContains('Max 1300x1300');
-    $this->assertSession()->pageTextContains('Max 2600x2600');
-
-    // Make sure all image styles has webp conversion as last effect.
-    foreach (ImageStyle::loadMultiple() as $style) {
-      $effects = $style->getEffects()->getInstanceIds();
-      $last = $style->getEffects()->get(end($effects));
-      $this->assertSame('image_convert', $last->getConfiguration()['id']);
-      $this->assertSame('webp', $last->getConfiguration()['data']['extension']);
-    }
-
-    // Verify certain routes' responses are cacheable by Dynamic Page Cache, to
-    // ensure these responses are very fast for authenticated users.
-    $this->drupalLogin($this->adminUser);
-    $url = Url::fromRoute('contact.site_page');
-    $this->drupalGet($url);
-    // Verify that site-wide contact page cannot be cached by Dynamic Page
-    // Cache.
-    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE');
-
-    $url = Url::fromRoute('<front>');
-    $this->drupalGet($url);
-    $this->drupalGet($url);
-    // Verify that frontpage is cached by Dynamic Page Cache.
-    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
-
-    $url = Url::fromRoute('entity.node.canonical', ['node' => 1]);
-    $this->drupalGet($url);
-    $this->drupalGet($url);
-    // Verify that full node page is cached by Dynamic Page Cache.
-    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
-
-    $url = Url::fromRoute('entity.user.canonical', ['user' => 1]);
-    $this->drupalGet($url);
-    $this->drupalGet($url);
-    // Verify that user profile page is cached by Dynamic Page Cache.
-    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
-
-    // Make sure the editorial workflow is installed after enabling the
-    // content_moderation module.
-    \Drupal::service('module_installer')->install(['content_moderation']);
-    $role = Role::create([
-      'id' => 'admin_workflows',
-      'label' => 'Admin workflow',
-    ]);
-    $role->grantPermission('administer workflows');
-    $role->save();
-    $this->adminUser->addRole($role->id());
-    $this->adminUser->save();
-    $this->rebuildContainer();
-    $this->drupalGet('admin/config/workflow/workflows/manage/editorial');
-    $this->assertSession()->pageTextContains('Draft');
-    $this->assertSession()->pageTextContains('Published');
-    $this->assertSession()->pageTextContains('Archived');
-    $this->assertSession()->pageTextContains('Create New Draft');
-    $this->assertSession()->pageTextContains('Publish');
-    $this->assertSession()->pageTextContains('Archive');
-    $this->assertSession()->pageTextContains('Restore to Draft');
-    $this->assertSession()->pageTextContains('Restore');
-
-    \Drupal::service('module_installer')->install(['media']);
-    $role = Role::create([
-      'id' => 'admin_media',
-      'label' => 'Admin media',
-    ]);
-    $role->grantPermission('administer media');
-    $role->grantPermission('administer media display');
-    $role->save();
-    $this->adminUser->addRole($role->id());
-    $this->adminUser->save();
-    $assert_session = $this->assertSession();
-    $page = $this->getSession()->getPage();
-    /** @var \Drupal\media\Entity\MediaType $media_type */
-    foreach (MediaType::loadMultiple() as $media_type) {
-      $media_type_machine_name = $media_type->id();
-      $this->drupalGet('media/add/' . $media_type_machine_name);
-      // Get the form element, and its HTML representation.
-      $form_selector = '#media-' . Html::cleanCssIdentifier($media_type_machine_name) . '-add-form';
-      $form = $assert_session->elementExists('css', $form_selector);
-      $form_html = $form->getOuterHtml();
-
-      // The name field should be hidden.
-      $assert_session->fieldNotExists('Name', $form);
-      // The source field should be shown before the vertical tabs.
-      $source_field_label = $media_type->getSource()->getSourceFieldDefinition($media_type)->getLabel();
-      $test_source_field = $assert_session->elementExists('xpath', "//*[contains(text(), '$source_field_label')]", $form)->getOuterHtml();
-      $vertical_tabs = $assert_session->elementExists('css', '.js-form-type-vertical-tabs', $form)->getOuterHtml();
-      $this->assertGreaterThan(strpos($form_html, $test_source_field), strpos($form_html, $vertical_tabs));
-      // The "Published" checkbox should be the last element.
-      $date_field = $assert_session->fieldExists('Date', $form)->getOuterHtml();
-      $published_checkbox = $assert_session->fieldExists('Published', $form)->getOuterHtml();
-      $this->assertGreaterThan(strpos($form_html, $date_field), strpos($form_html, $published_checkbox));
-      if (is_a($media_type->getSource(), Image::class, TRUE)) {
-        // Assert the default entity view display is configured with an image
-        // style.
-        $this->drupalGet('/admin/structure/media/manage/' . $media_type->id() . '/display');
-        $assert_session->fieldValueEquals('fields[field_media_image][type]', 'image');
-        $assert_session->elementTextContains('css', 'tr[data-drupal-selector="edit-fields-field-media-image"]', 'Image style: Large (480×480)');
-        // By default for media types with an image source, only the image
-        // component should be enabled.
-        $assert_session->elementsCount('css', 'input[name$="_settings_edit"]', 1);
-      }
-
-    }
-
-    // Tests that user 1 does not have an all-access pass.
-    $this->drupalLogin($this->rootUser);
-    $this->drupalGet('admin');
-    $this->assertSession()->statusCodeEquals(200);
-
-    User::load(1)
-      ->removeRole('administrator')
-      ->save();
-    // Clear caches so change take effect in system under test.
-    $this->rebuildAll();
-
-    $this->drupalGet('admin');
-    $this->assertSession()->statusCodeEquals(403);
-  }
-
 }
diff --git a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..e2e3c005219f68293907b4c343dd75c0ddc1ab42
--- /dev/null
+++ b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
@@ -0,0 +1,318 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\standard\Traits;
+
+use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
+use Drupal\Component\Utility\Html;
+use Drupal\contact\Entity\ContactForm;
+use Drupal\Core\Url;
+use Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber;
+use Drupal\editor\Entity\Editor;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\image\Entity\ImageStyle;
+use Drupal\media\Entity\MediaType;
+use Drupal\media\Plugin\media\Source\Image;
+use Drupal\Tests\RequirementsPageTrait;
+use Drupal\Tests\SchemaCheckTestTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\Entity\User;
+use Symfony\Component\Validator\ConstraintViolation;
+
+/**
+ * Provides a test method to test the Standard installation profile or recipe.
+ */
+trait StandardTestTrait {
+  use SchemaCheckTestTrait;
+  use RequirementsPageTrait;
+
+  /**
+   * The admin user.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $adminUser;
+
+  /**
+   * Tests Standard installation profile or recipe.
+   */
+  public function testStandard() {
+    $this->drupalGet('');
+    $this->assertSession()->pageTextContains('Powered by Drupal');
+    $this->assertSession()->pageTextContains('Congratulations and welcome to the Drupal community.');
+
+    // Test anonymous user can access 'Main navigation' block.
+    $this->adminUser = $this->drupalCreateUser([
+      'administer blocks',
+      'administer block content',
+      'post comments',
+      'skip comment approval',
+      'create article content',
+      'create page content',
+    ]);
+    $this->drupalLogin($this->adminUser);
+    // Configure the block.
+    $this->drupalGet('admin/structure/block/add/system_menu_block:main/olivero');
+    $this->submitForm([
+      'region' => 'sidebar',
+      'id' => 'main_navigation',
+    ], 'Save block');
+    // Verify admin user can see the block.
+    $this->drupalGet('');
+    $this->assertSession()->pageTextContains('Main navigation');
+
+    // Verify we have role = complementary on help_block blocks.
+    $this->drupalGet('admin/structure/block');
+    $this->assertSession()->elementAttributeContains('xpath', "//div[@id='block-olivero-help']", 'role', 'complementary');
+
+    // Verify anonymous user can see the block.
+    $this->drupalLogout();
+    $this->assertSession()->pageTextContains('Main navigation');
+
+    // Ensure comments don't show in the front page RSS feed.
+    // Create an article.
+    $this->drupalCreateNode([
+      'type' => 'article',
+      'title' => 'Foobar',
+      'promote' => 1,
+      'status' => 1,
+      'body' => [['value' => 'Then she picked out two somebodies,<br />Sally and me', 'format' => 'basic_html']],
+    ]);
+
+    // Add a comment.
+    $this->drupalLogin($this->adminUser);
+    $this->drupalGet('node/1');
+    // Verify that a line break is present.
+    $this->assertSession()->responseContains('Then she picked out two somebodies,<br>Sally and me');
+    $this->submitForm([
+      'subject[0][value]' => 'Bar foo',
+      'comment_body[0][value]' => 'Then she picked out two somebodies, Sally and me',
+    ], 'Save');
+    // Fetch the feed.
+    $this->drupalGet('rss.xml');
+    $this->assertSession()->responseContains('Foobar');
+    $this->assertSession()->responseNotContains('Then she picked out two somebodies, Sally and me');
+
+    // Ensure block body exists.
+    $this->drupalGet('block/add');
+    $this->assertSession()->fieldExists('body[0][value]');
+
+    // Now we have all configuration imported, test all of them for schema
+    // conformance. Ensures all imported default configuration is valid when
+    // standard profile modules are enabled.
+    $names = $this->container->get('config.storage')->listAll();
+    /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config */
+    $typed_config = $this->container->get('config.typed');
+    foreach ($names as $name) {
+      $config = $this->config($name);
+      $this->assertConfigSchema($typed_config, $name, $config->get());
+    }
+
+    // Validate all configuration.
+    // @todo Generalize in https://www.drupal.org/project/drupal/issues/2164373
+    foreach (Editor::loadMultiple() as $editor) {
+      // Currently only text editors using CKEditor 5 can be validated.
+      if ($editor->getEditor() !== 'ckeditor5') {
+        continue;
+      }
+
+      $this->assertSame([], array_map(
+        function (ConstraintViolation $v) {
+          return (string) $v->getMessage();
+        },
+        iterator_to_array(CKEditor5::validatePair(
+          $editor,
+          $editor->getFilterFormat()
+        ))
+      ));
+    }
+
+    // Ensure that configuration from the Standard profile is not reused when
+    // enabling a module again since it contains configuration that can not be
+    // installed. For example, editor.editor.basic_html is editor configuration
+    // that depends on the CKEditor 5 module. The CKEditor 5 module can not be
+    // installed before the editor module since it depends on the editor module.
+    // The installer does not have this limitation since it ensures that all of
+    // the install profiles dependencies are installed before creating the
+    // editor configuration.
+    foreach (FilterFormat::loadMultiple() as $filter) {
+      // Ensure that editor can be uninstalled by removing use in filter
+      // formats. It is necessary to prime the filter collection before removing
+      // the filter.
+      $filter->filters();
+      $filter->removeFilter('editor_file_reference');
+      $filter->save();
+    }
+    \Drupal::service('module_installer')->uninstall(['editor', 'ckeditor5']);
+    $this->rebuildContainer();
+    \Drupal::service('module_installer')->install(['editor']);
+    /** @var \Drupal\contact\ContactFormInterface $contact_form */
+    $contact_form = ContactForm::load('feedback');
+    $recipients = $contact_form->getRecipients();
+    $this->assertEquals(['simpletest@example.com'], $recipients);
+
+    $role = Role::create([
+      'id' => 'admin_theme',
+      'label' => 'Admin theme',
+    ]);
+    $role->grantPermission('view the administration theme');
+    $role->save();
+    $this->adminUser->addRole($role->id());
+    $this->adminUser->save();
+    $this->drupalGet('node/add');
+    $this->assertSession()->statusCodeEquals(200);
+
+    // Ensure that there are no pending updates after installation.
+    $this->drupalLogin($this->rootUser);
+    $this->drupalGet('update.php/selection');
+    $this->updateRequirementsProblem();
+    $this->drupalGet('update.php/selection');
+    $this->assertSession()->pageTextContains('No pending updates.');
+
+    // Ensure that there are no pending entity updates after installation.
+    $this->assertFalse($this->container->get('entity.definition_update_manager')->needsUpdates(), 'After installation, entity schema is up to date.');
+
+    // Make sure the optional image styles are not installed.
+    $this->drupalGet('admin/config/media/image-styles');
+    $this->assertSession()->pageTextNotContains('Max 325x325');
+    $this->assertSession()->pageTextNotContains('Max 650x650');
+    $this->assertSession()->pageTextNotContains('Max 1300x1300');
+    $this->assertSession()->pageTextNotContains('Max 2600x2600');
+
+    // Make sure the optional image styles are installed after enabling
+    // the responsive_image module.
+    $this->installResponsiveImage();
+    $this->drupalGet('admin/config/media/image-styles');
+    $this->assertSession()->pageTextContains('Max 325x325');
+    $this->assertSession()->pageTextContains('Max 650x650');
+    $this->assertSession()->pageTextContains('Max 1300x1300');
+    $this->assertSession()->pageTextContains('Max 2600x2600');
+
+    // Make sure all image styles has webp conversion as last effect.
+    foreach (ImageStyle::loadMultiple() as $style) {
+      $effects = $style->getEffects()->getInstanceIds();
+      $last = $style->getEffects()->get(end($effects));
+      $this->assertSame('image_convert', $last->getConfiguration()['id']);
+      $this->assertSame('webp', $last->getConfiguration()['data']['extension']);
+    }
+
+    // Verify certain routes' responses are cacheable by Dynamic Page Cache, to
+    // ensure these responses are very fast for authenticated users.
+    $this->drupalLogin($this->adminUser);
+    $url = Url::fromRoute('contact.site_page');
+    $this->drupalGet($url);
+    // Verify that site-wide contact page cannot be cached by Dynamic Page
+    // Cache.
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE');
+
+    $url = Url::fromRoute('<front>');
+    $this->drupalGet($url);
+    $this->drupalGet($url);
+    // Verify that frontpage is cached by Dynamic Page Cache.
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
+
+    $url = Url::fromRoute('entity.node.canonical', ['node' => 1]);
+    $this->drupalGet($url);
+    $this->drupalGet($url);
+    // Verify that full node page is cached by Dynamic Page Cache.
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
+
+    $url = Url::fromRoute('entity.user.canonical', ['user' => 1]);
+    $this->drupalGet($url);
+    $this->drupalGet($url);
+    // Verify that user profile page is cached by Dynamic Page Cache.
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
+
+    // Make sure the editorial workflow is installed after enabling the
+    // content_moderation module.
+    \Drupal::service('module_installer')->install(['content_moderation']);
+    $role = Role::create([
+      'id' => 'admin_workflows',
+      'label' => 'Admin workflow',
+    ]);
+    $role->grantPermission('administer workflows');
+    $role->save();
+    $this->adminUser->addRole($role->id());
+    $this->adminUser->save();
+    $this->rebuildContainer();
+    $this->drupalGet('admin/config/workflow/workflows/manage/editorial');
+    $this->assertSession()->pageTextContains('Draft');
+    $this->assertSession()->pageTextContains('Published');
+    $this->assertSession()->pageTextContains('Archived');
+    $this->assertSession()->pageTextContains('Create New Draft');
+    $this->assertSession()->pageTextContains('Publish');
+    $this->assertSession()->pageTextContains('Archive');
+    $this->assertSession()->pageTextContains('Restore to Draft');
+    $this->assertSession()->pageTextContains('Restore');
+
+    \Drupal::service('module_installer')->install(['media']);
+    $role = Role::create([
+      'id' => 'admin_media',
+      'label' => 'Admin media',
+    ]);
+    $role->grantPermission('administer media');
+    $role->grantPermission('administer media display');
+    $role->save();
+    $this->adminUser->addRole($role->id());
+    $this->adminUser->save();
+    $assert_session = $this->assertSession();
+    $page = $this->getSession()->getPage();
+    /** @var \Drupal\media\Entity\MediaType $media_type */
+    foreach (MediaType::loadMultiple() as $media_type) {
+      $media_type_machine_name = $media_type->id();
+      $this->drupalGet('media/add/' . $media_type_machine_name);
+      // Get the form element, and its HTML representation.
+      $form_selector = '#media-' . Html::cleanCssIdentifier($media_type_machine_name) . '-add-form';
+      $form = $assert_session->elementExists('css', $form_selector);
+      $form_html = $form->getOuterHtml();
+
+      // The name field should be hidden.
+      $assert_session->fieldNotExists('Name', $form);
+      // The source field should be shown before the vertical tabs.
+      $source_field_label = $media_type->getSource()->getSourceFieldDefinition($media_type)->getLabel();
+      $test_source_field = $assert_session->elementExists('xpath', "//*[contains(text(), '$source_field_label')]", $form)->getOuterHtml();
+      $vertical_tabs = $assert_session->elementExists('css', '.js-form-type-vertical-tabs', $form)->getOuterHtml();
+      $this->assertGreaterThan(strpos($form_html, $test_source_field), strpos($form_html, $vertical_tabs));
+      // The "Published" checkbox should be the last element.
+      $date_field = $assert_session->fieldExists('Date', $form)->getOuterHtml();
+      $published_checkbox = $assert_session->fieldExists('Published', $form)->getOuterHtml();
+      $this->assertGreaterThan(strpos($form_html, $date_field), strpos($form_html, $published_checkbox));
+      if (is_a($media_type->getSource(), Image::class, TRUE)) {
+        // Assert the default entity view display is configured with an image
+        // style.
+        $this->drupalGet('/admin/structure/media/manage/' . $media_type->id() . '/display');
+        $assert_session->fieldValueEquals('fields[field_media_image][type]', 'image');
+        $assert_session->elementTextContains('css', 'tr[data-drupal-selector="edit-fields-field-media-image"]', 'Image style: Large (480×480)');
+        // By default for media types with an image source, only the image
+        // component should be enabled.
+        $assert_session->elementsCount('css', 'input[name$="_settings_edit"]', 1);
+      }
+
+    }
+
+    // Tests that user 1 does not have an all-access pass.
+    $this->drupalLogin($this->rootUser);
+    $this->drupalGet('admin');
+    $this->assertSession()->statusCodeEquals(200);
+
+    User::load(1)
+      ->removeRole('administrator')
+      ->save();
+    // Clear caches so change take effect in system under test.
+    $this->rebuildAll();
+
+    $this->drupalGet('admin');
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Installs the responsive image module.
+   */
+  protected function installResponsiveImage(): void {
+    // Install responsive_image module.
+    \Drupal::service('module_installer')->install(['responsive_image']);
+    $this->rebuildContainer();
+  }
+
+}
diff --git a/core/recipes/article_comment/config/field.field.node.article.comment.yml b/core/recipes/article_comment/config/field.field.node.article.comment.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cf3b12af98f5e10296a4e896f974f8aa5487155b
--- /dev/null
+++ b/core/recipes/article_comment/config/field.field.node.article.comment.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.node.comment
+    - node.type.article
+  module:
+    - comment
+id: node.article.comment
+field_name: comment
+entity_type: node
+bundle: article
+label: Comments
+description: ''
+required: false
+translatable: true
+default_value:
+  -
+    status: 2
+    cid: 0
+    last_comment_timestamp: 0
+    last_comment_name: null
+    last_comment_uid: 0
+    comment_count: 0
+default_value_callback: ''
+settings:
+  default_mode: 1
+  per_page: 50
+  anonymous: 0
+  form_location: true
+  preview: 1
+field_type: comment
diff --git a/core/recipes/article_comment/recipe.yml b/core/recipes/article_comment/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f4ac4e4be49a2a7d59638ea8a041ef97c2c00497
--- /dev/null
+++ b/core/recipes/article_comment/recipe.yml
@@ -0,0 +1,29 @@
+name: 'Article comments'
+description: 'Provides commenting on article content.'
+type: 'Content field'
+recipes:
+  - article_content_type
+  - comment_base
+config:
+  actions:
+    core.entity_form_display.node.article.default:
+      setComponent:
+        name: comment
+        options:
+          type: comment_default
+          weight: 20
+          region: content
+          settings: {}
+          third_party_settings: {}
+    core.entity_view_display.node.article.default:
+      setComponent:
+        name: comment
+        options:
+          type: comment_default
+          label: above
+          settings:
+            view_mode: default
+            pager_id: 0
+          third_party_settings: {  }
+          weight: 110
+          region: content
diff --git a/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml b/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f29f17bc0465a876007e8081219dc56b42b7f22b
--- /dev/null
+++ b/core/recipes/article_content_type/config/core.entity_form_display.node.article.default.yml
@@ -0,0 +1,87 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.node.article.body
+    - field.field.node.article.field_image
+    - image.style.thumbnail
+    - node.type.article
+  module:
+    - image
+    - path
+    - text
+id: node.article.default
+targetEntityType: node
+bundle: article
+mode: default
+content:
+  body:
+    type: text_textarea_with_summary
+    weight: 2
+    region: content
+    settings:
+      rows: 9
+      summary_rows: 3
+      placeholder: ''
+      show_summary: false
+    third_party_settings: {  }
+  created:
+    type: datetime_timestamp
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_image:
+    type: image_image
+    weight: 1
+    region: content
+    settings:
+      progress_indicator: throbber
+      preview_image_style: thumbnail
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 30
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  promote:
+    type: boolean_checkbox
+    weight: 15
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    weight: 120
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  sticky:
+    type: boolean_checkbox
+    weight: 16
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  title:
+    type: string_textfield
+    weight: 0
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 5
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden: {  }
diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a129e14dd6a938959de9de919355e91c163e3027
--- /dev/null
+++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.default.yml
@@ -0,0 +1,41 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.node.article.body
+    - field.field.node.article.field_image
+    - image.style.wide
+    - node.type.article
+  module:
+    - image
+    - text
+    - user
+id: node.article.default
+targetEntityType: node
+bundle: article
+mode: default
+content:
+  body:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 0
+    region: content
+  field_image:
+    type: image
+    label: hidden
+    settings:
+      image_style: wide
+      image_link: ''
+      image_loading:
+        attribute: eager
+    third_party_settings: {  }
+    weight: -1
+    region: content
+  links:
+    settings: {  }
+    third_party_settings: {  }
+    weight: 100
+    region: content
+hidden: {  }
diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml
new file mode 100644
index 0000000000000000000000000000000000000000..05896dd3d74c1b83295087f7f789464b45963eb7
--- /dev/null
+++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.rss.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.node.rss
+    - field.field.node.article.body
+    - field.field.node.article.field_image
+    - node.type.article
+  module:
+    - user
+id: node.article.rss
+targetEntityType: node
+bundle: article
+mode: rss
+content:
+  links:
+    weight: 100
+    region: content
+hidden:
+  body: true
+  field_image: true
diff --git a/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml b/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5ef60b519f424b33a4bbc4509c3a7db6ee637edf
--- /dev/null
+++ b/core/recipes/article_content_type/config/core.entity_view_display.node.article.teaser.yml
@@ -0,0 +1,41 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.node.teaser
+    - field.field.node.article.body
+    - field.field.node.article.field_image
+    - image.style.medium
+    - node.type.article
+  module:
+    - image
+    - text
+    - user
+id: node.article.teaser
+targetEntityType: node
+bundle: article
+mode: teaser
+content:
+  body:
+    type: text_summary_or_trimmed
+    label: hidden
+    settings:
+      trim_length: 600
+    third_party_settings: {  }
+    weight: 0
+    region: content
+  field_image:
+    type: image
+    label: hidden
+    settings:
+      image_style: medium
+      image_link: content
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: -1
+    region: content
+  links:
+    weight: 100
+    region: content
+hidden: {  }
diff --git a/core/recipes/article_content_type/config/field.field.node.article.body.yml b/core/recipes/article_content_type/config/field.field.node.article.body.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b36fbd584493fcbd6c7d5a813b458845cdd67dd7
--- /dev/null
+++ b/core/recipes/article_content_type/config/field.field.node.article.body.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.node.body
+    - node.type.article
+  module:
+    - text
+id: node.article.body
+field_name: body
+entity_type: node
+bundle: article
+label: Body
+description: ''
+required: false
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  display_summary: true
+  required_summary: false
+field_type: text_with_summary
diff --git a/core/recipes/article_content_type/config/field.field.node.article.field_image.yml b/core/recipes/article_content_type/config/field.field.node.article.field_image.yml
new file mode 100644
index 0000000000000000000000000000000000000000..af4daeca6d0564561bc58941752a6f0c07db3c51
--- /dev/null
+++ b/core/recipes/article_content_type/config/field.field.node.article.field_image.yml
@@ -0,0 +1,37 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.node.field_image
+    - node.type.article
+  module:
+    - image
+id: node.article.field_image
+field_name: field_image
+entity_type: node
+bundle: article
+label: Image
+description: ''
+required: false
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  handler: 'default:file'
+  handler_settings: {  }
+  file_directory: '[date:custom:Y]-[date:custom:m]'
+  file_extensions: 'png gif jpg jpeg webp'
+  max_filesize: ''
+  max_resolution: ''
+  min_resolution: ''
+  alt_field: true
+  alt_field_required: true
+  title_field: false
+  title_field_required: false
+  default_image:
+    uuid: null
+    alt: ''
+    title: ''
+    width: null
+    height: null
+field_type: image
diff --git a/core/recipes/article_content_type/config/field.storage.node.field_image.yml b/core/recipes/article_content_type/config/field.storage.node.field_image.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a6964d3b0aabe3f63b497d4619afde15408f349a
--- /dev/null
+++ b/core/recipes/article_content_type/config/field.storage.node.field_image.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - file
+    - image
+    - node
+id: node.field_image
+field_name: field_image
+entity_type: node
+type: image
+settings:
+  target_type: file
+  display_field: false
+  display_default: false
+  uri_scheme: public
+  default_image:
+    uuid: null
+    alt: ''
+    title: ''
+    width: null
+    height: null
+module: image
+locked: false
+cardinality: 1
+translatable: true
+indexes:
+  target_id:
+    - target_id
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/article_content_type/config/node.type.article.yml b/core/recipes/article_content_type/config/node.type.article.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ae8e9d12580b22cb2a38e40384818ac1bff2ce79
--- /dev/null
+++ b/core/recipes/article_content_type/config/node.type.article.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies: {  }
+name: Article
+type: article
+description: 'Use <em>articles</em> for time-sensitive content like news, press releases or blog posts.'
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: true
diff --git a/core/recipes/article_content_type/recipe.yml b/core/recipes/article_content_type/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..30eaf30b121abb565e8b49a1c1780ad313553131
--- /dev/null
+++ b/core/recipes/article_content_type/recipe.yml
@@ -0,0 +1,28 @@
+name: 'Article content type'
+description: 'Provides Article content type and related configuration. Use <em>articles</em> for time-sensitive content like news, press releases or blog posts.'
+type: 'Content type'
+install:
+  - image
+  - node
+  - path
+config:
+  import:
+    node:
+      # Only import config which is also imported by the Standard profile.
+      - core.entity_view_mode.node.full
+      - core.entity_view_mode.node.rss
+      - core.entity_view_mode.node.teaser
+      - field.storage.node.body
+      - system.action.node_delete_action
+      - system.action.node_make_sticky_action
+      - system.action.node_make_unsticky_action
+      - system.action.node_promote_action
+      - system.action.node_publish_action
+      - system.action.node_save_action
+      - system.action.node_unpromote_action
+      - system.action.node_unpublish_action
+    image:
+      # Only import config which is also imported by the Standard profile.
+      - image.style.medium
+      - image.style.thumbnail
+      - image.style.wide
diff --git a/core/recipes/article_tags/config/field.field.node.article.field_tags.yml b/core/recipes/article_tags/config/field.field.node.article.field_tags.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1b9c4cc4ee1e1143722aeac9ef20e9aaaf27199d
--- /dev/null
+++ b/core/recipes/article_tags/config/field.field.node.article.field_tags.yml
@@ -0,0 +1,26 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.node.field_tags
+    - node.type.article
+    - taxonomy.vocabulary.tags
+id: node.article.field_tags
+field_name: field_tags
+entity_type: node
+bundle: article
+label: Tags
+description: 'Enter a comma-separated list. For example: Amsterdam, Mexico City, "Cleveland, Ohio"'
+required: false
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  handler: 'default:taxonomy_term'
+  handler_settings:
+    target_bundles:
+      tags: tags
+    sort:
+      field: _none
+    auto_create: true
+field_type: entity_reference
diff --git a/core/recipes/article_tags/config/field.storage.node.field_tags.yml b/core/recipes/article_tags/config/field.storage.node.field_tags.yml
new file mode 100644
index 0000000000000000000000000000000000000000..73f821f2c0dea6f2e8cfbd7ddc14795dcda91acd
--- /dev/null
+++ b/core/recipes/article_tags/config/field.storage.node.field_tags.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - node
+    - taxonomy
+id: node.field_tags
+field_name: field_tags
+entity_type: node
+type: entity_reference
+settings:
+  target_type: taxonomy_term
+module: core
+locked: false
+cardinality: -1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/article_tags/recipe.yml b/core/recipes/article_tags/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..59566afecb798e0496b8505f367b46503cbb77dc
--- /dev/null
+++ b/core/recipes/article_tags/recipe.yml
@@ -0,0 +1,39 @@
+name: 'Article tags'
+description: 'Provides tags on article content.'
+type: 'Content field'
+recipes:
+  - article_content_type
+  - tags_taxonomy
+install:
+  - views
+config:
+  import:
+    taxonomy:
+      - views.view.taxonomy_term
+  actions:
+    core.entity_form_display.node.article.default:
+      setComponent:
+        name: field_tags
+        options:
+          type: entity_reference_autocomplete_tags
+          weight: 3
+          region: content
+          settings:
+            match_operator: CONTAINS
+            match_limit: 10
+            size: 60
+            placeholder: ''
+          third_party_settings: {  }
+    core.entity_view_display.node.article.teaser: &entity_view_display_node_article_teaser
+      setComponent:
+        name: field_tags
+        options:
+          type: entity_reference_label
+          label: above
+          settings:
+            link: true
+          third_party_settings: {  }
+          weight: 10
+          region: content
+    core.entity_view_display.node.article.default:
+      <<: *entity_view_display_node_article_teaser
diff --git a/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..55854bcb88ab1b8c288c4ef2b351286cf179f8d3
--- /dev/null
+++ b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.default.yml
@@ -0,0 +1,52 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.audio.field_media_audio_file
+    - media.type.audio
+  module:
+    - file
+    - path
+id: media.audio.default
+targetEntityType: media
+bundle: audio
+mode: default
+content:
+  created:
+    type: datetime_timestamp
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_media_audio_file:
+    type: file_generic
+    weight: 0
+    region: content
+    settings:
+      progress_indicator: throbber
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 30
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    weight: 100
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 5
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden:
+  name: true
diff --git a/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9179618e640c9cc7427ba576765cda0dd5b93a26
--- /dev/null
+++ b/core/recipes/audio_media_type/config/core.entity_form_display.media.audio.media_library.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_form_mode.media.media_library
+    - field.field.media.audio.field_media_audio_file
+    - media.type.audio
+id: media.audio.media_library
+targetEntityType: media
+bundle: audio
+mode: media_library
+content: {  }
+hidden:
+  created: true
+  field_media_audio_file: true
+  name: true
+  path: true
+  status: true
+  uid: true
diff --git a/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2956f6913195ea52a6b768979df7ec400235b4f7
--- /dev/null
+++ b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.default.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.audio.field_media_audio_file
+    - media.type.audio
+  module:
+    - file
+id: media.audio.default
+targetEntityType: media
+bundle: audio
+mode: default
+content:
+  field_media_audio_file:
+    type: file_audio
+    label: visually_hidden
+    settings:
+      controls: true
+      autoplay: false
+      loop: false
+      multiple_file_display_type: tags
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  created: true
+  name: true
+  thumbnail: true
+  uid: true
diff --git a/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..85dac7b2db425191726d4ea41127a6c2de909b21
--- /dev/null
+++ b/core/recipes/audio_media_type/config/core.entity_view_display.media.audio.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.media.media_library
+    - field.field.media.audio.field_media_audio_file
+    - image.style.thumbnail
+    - media.type.audio
+  module:
+    - image
+id: media.audio.media_library
+targetEntityType: media
+bundle: audio
+mode: media_library
+content:
+  thumbnail:
+    type: image
+    label: hidden
+    settings:
+      image_style: thumbnail
+      image_link: ''
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  created: true
+  field_media_audio_file: true
+  name: true
+  uid: true
diff --git a/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml b/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a4bb52eb859a2a18c2409964ea3c6d3ee1e0e579
--- /dev/null
+++ b/core/recipes/audio_media_type/config/field.field.media.audio.field_media_audio_file.yml
@@ -0,0 +1,26 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.media.field_media_audio_file
+    - media.type.audio
+  module:
+    - file
+id: media.audio.field_media_audio_file
+field_name: field_media_audio_file
+entity_type: media
+bundle: audio
+label: 'Audio file'
+description: ''
+required: true
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  handler: 'default:file'
+  handler_settings: {  }
+  file_directory: '[date:custom:Y]-[date:custom:m]'
+  file_extensions: 'mp3 wav aac'
+  max_filesize: ''
+  description_field: false
+field_type: file
diff --git a/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml b/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1626b607eacd50d1aea611d925fd34b8307b26c1
--- /dev/null
+++ b/core/recipes/audio_media_type/config/field.storage.media.field_media_audio_file.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - file
+    - media
+id: media.field_media_audio_file
+field_name: field_media_audio_file
+entity_type: media
+type: file
+settings:
+  target_type: file
+  display_field: false
+  display_default: false
+  uri_scheme: public
+module: file
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/audio_media_type/config/media.type.audio.yml b/core/recipes/audio_media_type/config/media.type.audio.yml
new file mode 100644
index 0000000000000000000000000000000000000000..233b2042dc4194781d46c199a6064af0de688506
--- /dev/null
+++ b/core/recipes/audio_media_type/config/media.type.audio.yml
@@ -0,0 +1,13 @@
+langcode: en
+status: true
+dependencies: {  }
+id: audio
+label: Audio
+description: 'A locally hosted audio file.'
+source: audio_file
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+  source_field: field_media_audio_file
+field_map:
+  name: name
diff --git a/core/recipes/audio_media_type/recipe.yml b/core/recipes/audio_media_type/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bd86d21d819439266473758fd13744ac97c28f51
--- /dev/null
+++ b/core/recipes/audio_media_type/recipe.yml
@@ -0,0 +1,27 @@
+name: 'Audio media'
+description: 'Provides "Audio" media type and related configuration. A locally hosted audio file.'
+type: 'Media type'
+install:
+  - image
+  - media_library
+  - path
+  - views
+config:
+  import:
+    file:
+      - views.view.files
+    media_library:
+      - core.entity_view_display.media.audio.media_library
+      - core.entity_view_mode.media.media_library
+      - core.entity_form_mode.media.media_library
+      - image.style.media_library
+      - views.view.media_library
+    media:
+      - core.entity_view_mode.media.full
+      - system.action.media_delete_action
+      - system.action.media_publish_action
+      - system.action.media_save_action
+      - system.action.media_unpublish_action
+      - views.view.media
+    image:
+      - image.style.thumbnail
diff --git a/core/recipes/basic_block_type/config/block_content.type.basic.yml b/core/recipes/basic_block_type/config/block_content.type.basic.yml
new file mode 100644
index 0000000000000000000000000000000000000000..52ee484241013cc785dbf51ce2834cef83935cfd
--- /dev/null
+++ b/core/recipes/basic_block_type/config/block_content.type.basic.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+id: basic
+label: 'Basic block'
+revision: false
+description: 'A basic block contains a title and a body.'
diff --git a/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml b/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fe49840e80b25cecceb4e285f598ce0ad3eab8c1
--- /dev/null
+++ b/core/recipes/basic_block_type/config/core.entity_form_display.block_content.basic.default.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - block_content.type.basic
+    - field.field.block_content.basic.body
+  module:
+    - text
+id: block_content.basic.default
+targetEntityType: block_content
+bundle: basic
+mode: default
+content:
+  body:
+    type: text_textarea_with_summary
+    weight: -4
+    region: content
+    settings:
+      rows: 9
+      summary_rows: 3
+      placeholder: ''
+      show_summary: false
+    third_party_settings: {  }
+  info:
+    type: string_textfield
+    weight: -5
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden: {  }
diff --git a/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml b/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f4bb96567d4c43fae23e28d563a405967818ea34
--- /dev/null
+++ b/core/recipes/basic_block_type/config/core.entity_view_display.block_content.basic.default.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - block_content.type.basic
+    - field.field.block_content.basic.body
+  module:
+    - text
+id: block_content.basic.default
+targetEntityType: block_content
+bundle: basic
+mode: default
+content:
+  body:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden: {  }
diff --git a/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml b/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml
new file mode 100644
index 0000000000000000000000000000000000000000..dab4f9818109a13e668e02bb3105ad024cae6189
--- /dev/null
+++ b/core/recipes/basic_block_type/config/field.field.block_content.basic.body.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - block_content.type.basic
+    - field.storage.block_content.body
+  module:
+    - text
+id: block_content.basic.body
+field_name: body
+entity_type: block_content
+bundle: basic
+label: Body
+description: ''
+required: false
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  display_summary: false
+  required_summary: false
+field_type: text_with_summary
diff --git a/core/recipes/basic_block_type/recipe.yml b/core/recipes/basic_block_type/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d1d7fa8755e2d04789cccce89c8fd8c954c77610
--- /dev/null
+++ b/core/recipes/basic_block_type/recipe.yml
@@ -0,0 +1,12 @@
+name: 'Basic block'
+description: 'Provides "Basic block" custom block type and related configuration. A basic block contains a title and a body.'
+type: 'Block type'
+install:
+  - block_content
+  - views
+config:
+  import:
+    block_content:
+      - field.storage.block_content.body
+      - core.entity_view_mode.block_content.full
+      - views.view.block_content
diff --git a/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml b/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a31e41506fd5df040a3f84e324c6643867425c1f
--- /dev/null
+++ b/core/recipes/basic_html_format_editor/config/editor.editor.basic_html.yml
@@ -0,0 +1,65 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - filter.format.basic_html
+  module:
+    - ckeditor5
+format: basic_html
+editor: ckeditor5
+settings:
+  toolbar:
+    items:
+      - bold
+      - italic
+      - '|'
+      - link
+      - '|'
+      - bulletedList
+      - numberedList
+      - '|'
+      - blockQuote
+      - drupalInsertImage
+      - '|'
+      - heading
+      - code
+      - '|'
+      - sourceEditing
+  plugins:
+    ckeditor5_heading:
+      enabled_headings:
+        - heading2
+        - heading3
+        - heading4
+        - heading5
+        - heading6
+    ckeditor5_imageResize:
+      allow_resize: true
+    ckeditor5_list:
+      properties:
+        reversed: false
+        startIndex: true
+      multiBlock: true
+    ckeditor5_sourceEditing:
+      allowed_tags:
+        - '<cite>'
+        - '<dl>'
+        - '<dt>'
+        - '<dd>'
+        - '<a hreflang>'
+        - '<blockquote cite>'
+        - '<ul type>'
+        - '<ol type>'
+        - '<h2 id>'
+        - '<h3 id>'
+        - '<h4 id>'
+        - '<h5 id>'
+        - '<h6 id>'
+image_upload:
+  status: true
+  scheme: public
+  directory: inline-images
+  max_size: null
+  max_dimensions:
+    width: null
+    height: null
diff --git a/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml b/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d81fc17303f258cdc486754a03e9b2cd706bf2f1
--- /dev/null
+++ b/core/recipes/basic_html_format_editor/config/filter.format.basic_html.yml
@@ -0,0 +1,50 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - editor
+name: 'Basic HTML'
+format: basic_html
+weight: 0
+roles:
+  - authenticated
+filters:
+  editor_file_reference:
+    id: editor_file_reference
+    provider: editor
+    status: true
+    weight: 11
+    settings: {  }
+  filter_align:
+    id: filter_align
+    provider: filter
+    status: true
+    weight: 7
+    settings: {  }
+  filter_caption:
+    id: filter_caption
+    provider: filter
+    status: true
+    weight: 8
+    settings: {  }
+  filter_html:
+    id: filter_html
+    provider: filter
+    status: true
+    weight: -10
+    settings:
+      allowed_html: '<br> <p> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id> <cite> <dl> <dt> <dd> <a hreflang href> <blockquote cite> <ul type> <ol start type> <strong> <em> <code> <li> <img src alt data-entity-uuid data-entity-type height width data-caption data-align>'
+      filter_html_help: false
+      filter_html_nofollow: false
+  filter_html_image_secure:
+    id: filter_html_image_secure
+    provider: filter
+    status: true
+    weight: 9
+    settings: {  }
+  filter_image_lazy_load:
+    id: filter_image_lazy_load
+    provider: filter
+    status: true
+    weight: 15
+    settings: {  }
diff --git a/core/recipes/basic_html_format_editor/recipe.yml b/core/recipes/basic_html_format_editor/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d1e5e6ec8ca6120c5e4f4e3020dd4d3bb57d7360
--- /dev/null
+++ b/core/recipes/basic_html_format_editor/recipe.yml
@@ -0,0 +1,9 @@
+name: 'Basic HTML editor'
+description: 'Provides "Basic HTML" text format along with WYSIWYG editor and related configuration.'
+type: 'Text format editor'
+install:
+  - ckeditor5
+config:
+  actions:
+    user.role.authenticated:
+      grantPermission: 'use text format basic_html'
diff --git a/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml b/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml
new file mode 100644
index 0000000000000000000000000000000000000000..13985a85739b5d594f3a7d8358e2345fccde91d0
--- /dev/null
+++ b/core/recipes/basic_shortcuts/content/shortcut/478b3170-1dfd-49d8-8eb3-f1b285445ab7.yml
@@ -0,0 +1,18 @@
+_meta:
+  version: '1.0'
+  entity_type: shortcut
+  uuid: 478b3170-1dfd-49d8-8eb3-f1b285445ab7
+  bundle: default
+  default_langcode: en
+default:
+  title:
+    -
+      value: 'All content'
+  weight:
+    -
+      value: -19
+  link:
+    -
+      uri: 'internal:/admin/content'
+      title: ''
+      options: {  }
diff --git a/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml b/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ad2dc75d6871ca91ea3580ea6344b434285a1b80
--- /dev/null
+++ b/core/recipes/basic_shortcuts/content/shortcut/d5377721-d6de-4fdf-82e2-557c50f84ceb.yml
@@ -0,0 +1,18 @@
+_meta:
+  version: '1.0'
+  entity_type: shortcut
+  uuid: d5377721-d6de-4fdf-82e2-557c50f84ceb
+  bundle: default
+  default_langcode: en
+default:
+  title:
+    -
+      value: 'Add content'
+  weight:
+    -
+      value: -20
+  link:
+    -
+      uri: 'internal:/node/add'
+      title: ''
+      options: {  }
diff --git a/core/recipes/basic_shortcuts/recipe.yml b/core/recipes/basic_shortcuts/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8f52500eacef26cca4b31bf926e10c2270971786
--- /dev/null
+++ b/core/recipes/basic_shortcuts/recipe.yml
@@ -0,0 +1,12 @@
+name: 'Basic shortcuts'
+description: 'Provides a basic set of shortcuts for logged-in users.'
+type: Administration
+install:
+  - shortcut
+config:
+  import:
+    shortcut:
+      - shortcut.set.default
+  actions:
+    user.role.authenticated:
+      grantPermission: 'access shortcuts'
diff --git a/core/recipes/comment_base/config/comment.type.comment.yml b/core/recipes/comment_base/config/comment.type.comment.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ddcbbc986095a6bfd062679f0990d011b956db97
--- /dev/null
+++ b/core/recipes/comment_base/config/comment.type.comment.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+id: comment
+label: 'Default comments'
+target_entity_type_id: node
+description: 'Allows commenting on content'
diff --git a/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml b/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1010be292488569234b654548ba3234bf8d9ae1e
--- /dev/null
+++ b/core/recipes/comment_base/config/core.entity_form_display.comment.comment.default.yml
@@ -0,0 +1,33 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - comment.type.comment
+    - field.field.comment.comment.comment_body
+  module:
+    - text
+id: comment.comment.default
+targetEntityType: comment
+bundle: comment
+mode: default
+content:
+  author:
+    weight: -2
+    region: content
+  comment_body:
+    type: text_textarea
+    weight: 11
+    region: content
+    settings:
+      rows: 5
+      placeholder: ''
+    third_party_settings: {  }
+  subject:
+    type: string_textfield
+    weight: 10
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden: {  }
diff --git a/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml b/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b9fdd2bac71dfaf1a3caa8547127cbebd11b10a9
--- /dev/null
+++ b/core/recipes/comment_base/config/core.entity_view_display.comment.comment.default.yml
@@ -0,0 +1,24 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - comment.type.comment
+    - field.field.comment.comment.comment_body
+  module:
+    - text
+id: comment.comment.default
+targetEntityType: comment
+bundle: comment
+mode: default
+content:
+  comment_body:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 0
+    region: content
+  links:
+    weight: 100
+    region: content
+hidden: {  }
diff --git a/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml b/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1337070d16b859309099244a0e475bc7b6be2d77
--- /dev/null
+++ b/core/recipes/comment_base/config/field.field.comment.comment.comment_body.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - comment.type.comment
+    - field.storage.comment.comment_body
+  module:
+    - text
+id: comment.comment.comment_body
+field_name: comment_body
+entity_type: comment
+bundle: comment
+label: Comment
+description: ''
+required: true
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings: {  }
+field_type: text_long
diff --git a/core/recipes/comment_base/config/field.storage.node.comment.yml b/core/recipes/comment_base/config/field.storage.node.comment.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c5eee2c2841d49ca883d3254094e95962b2cd78c
--- /dev/null
+++ b/core/recipes/comment_base/config/field.storage.node.comment.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - comment
+    - node
+id: node.comment
+field_name: comment
+entity_type: node
+type: comment
+settings:
+  comment_type: comment
+module: comment
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/comment_base/recipe.yml b/core/recipes/comment_base/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0cf967155a4193f763c7dd7fa55dad87bebded8b
--- /dev/null
+++ b/core/recipes/comment_base/recipe.yml
@@ -0,0 +1,26 @@
+name: 'Default comments'
+description: 'Allows commenting on content.'
+type: 'Comment type'
+install:
+  - comment
+  - node
+  - views
+config:
+  import:
+    comment:
+      - core.entity_view_mode.comment.full
+      - field.storage.comment.comment_body
+      - system.action.comment_delete_action
+      - system.action.comment_publish_action
+      - system.action.comment_save_action
+      - system.action.comment_unpublish_action
+      - views.view.comment
+      - views.view.comments_recent
+  actions:
+    user.role.authenticated:
+      grantPermissions:
+        - 'access comments'
+        - 'post comments'
+        - 'skip comment approval'
+    user.role.anonymous:
+      grantPermission: 'access comments'
diff --git a/core/recipes/content_search/recipe.yml b/core/recipes/content_search/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b9f60c59a7f19c1c04c9d2b979c23a058789cf7f
--- /dev/null
+++ b/core/recipes/content_search/recipe.yml
@@ -0,0 +1,19 @@
+name: 'Content search'
+type: Search
+description: 'Adds a page that can search site content.'
+install:
+  - node
+  - search
+config:
+  import:
+    node:
+      - core.entity_view_mode.node.search_index
+      - core.entity_view_mode.node.search_result
+      - search.page.node_search
+  actions:
+    user.role.anonymous:
+      grantPermissions:
+        - 'search content'
+    user.role.authenticated:
+      grantPermissions:
+        - 'search content'
diff --git a/core/recipes/core_recommended_admin_theme/recipe.yml b/core/recipes/core_recommended_admin_theme/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5d60e2f5c9bfe6c8595951f5037b264b8794efb1
--- /dev/null
+++ b/core/recipes/core_recommended_admin_theme/recipe.yml
@@ -0,0 +1,24 @@
+name: 'Admin theme'
+description: 'Sets up Claro as the administrative (backend) theme.'
+type: 'Themes'
+install:
+  - claro
+  - block
+config:
+  import:
+    system:
+      - system.menu.account
+      - system.menu.main
+      - system.theme
+    claro:
+      - block.block.claro_breadcrumbs
+      - block.block.claro_content
+      - block.block.claro_local_actions
+      - block.block.claro_messages
+      - block.block.claro_page_title
+      - block.block.claro_primary_local_tasks
+      - block.block.claro_secondary_local_tasks
+  actions:
+    system.theme:
+      simple_config_update:
+        admin: claro
diff --git a/core/recipes/core_recommended_front_end_theme/recipe.yml b/core/recipes/core_recommended_front_end_theme/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cdfb36b369ee482b177480023cb116a6a3518ce2
--- /dev/null
+++ b/core/recipes/core_recommended_front_end_theme/recipe.yml
@@ -0,0 +1,29 @@
+name: 'Front end theme'
+description: 'Sets up Olivero as the front-end theme.'
+type: 'Themes'
+install:
+  - olivero
+  - block
+config:
+  import:
+    system:
+      - system.menu.account
+      - system.menu.main
+      - system.theme
+    olivero:
+      - block.block.olivero_account_menu
+      - block.block.olivero_breadcrumbs
+      - block.block.olivero_content
+      - block.block.olivero_main_menu
+      - block.block.olivero_messages
+      - block.block.olivero_page_title
+      - block.block.olivero_powered
+      - block.block.olivero_primary_admin_actions
+      - block.block.olivero_primary_local_tasks
+      - block.block.olivero_secondary_local_tasks
+      - block.block.olivero_site_branding
+      - core.date_format.olivero_medium
+  actions:
+    system.theme:
+      simple_config_update:
+        default: olivero
diff --git a/core/recipes/core_recommended_maintenance/recipe.yml b/core/recipes/core_recommended_maintenance/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ef6ad24ee645e9bb40c26e146924310968600cad
--- /dev/null
+++ b/core/recipes/core_recommended_maintenance/recipe.yml
@@ -0,0 +1,16 @@
+name: 'Recommended Maintenance'
+description: 'Sets up modules recommended for site maintenance.'
+type: 'Maintenance'
+install:
+  - automated_cron
+  - announcements_feed
+  - dblog
+  - views
+config:
+  import:
+    automated_cron:
+      - automated_cron.settings
+    dblog:
+      - views.view.watchdog
+    system:
+      - system.cron
diff --git a/core/recipes/core_recommended_performance/recipe.yml b/core/recipes/core_recommended_performance/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0a60e3f26ca9b9330ad880b20253dd892b5683cf
--- /dev/null
+++ b/core/recipes/core_recommended_performance/recipe.yml
@@ -0,0 +1,7 @@
+name: 'Recommended Performance'
+description: 'Sets up modules for improved site performance.'
+type: 'Performance'
+install:
+  - page_cache
+  - dynamic_page_cache
+  - big_pipe
diff --git a/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml b/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7233f32e06566a11d30d922b554291b41509e4d0
--- /dev/null
+++ b/core/recipes/document_media_type/config/core.entity_form_display.media.document.default.yml
@@ -0,0 +1,52 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.document.field_media_document
+    - media.type.document
+  module:
+    - file
+    - path
+id: media.document.default
+targetEntityType: media
+bundle: document
+mode: default
+content:
+  created:
+    type: datetime_timestamp
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_media_document:
+    type: file_generic
+    weight: 0
+    region: content
+    settings:
+      progress_indicator: throbber
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 30
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    weight: 100
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 5
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden:
+  name: true
diff --git a/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml b/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b7abbe903fe894bb3c951716edd1d0471f95f69d
--- /dev/null
+++ b/core/recipes/document_media_type/config/core.entity_form_display.media.document.media_library.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_form_mode.media.media_library
+    - field.field.media.document.field_media_document
+    - media.type.document
+id: media.document.media_library
+targetEntityType: media
+bundle: document
+mode: media_library
+content: {  }
+hidden:
+  created: true
+  field_media_document: true
+  name: true
+  path: true
+  status: true
+  uid: true
diff --git a/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml b/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0c44314110750142aa4a9d7d91ece1527dcc2c3a
--- /dev/null
+++ b/core/recipes/document_media_type/config/core.entity_view_display.media.document.default.yml
@@ -0,0 +1,25 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.document.field_media_document
+    - media.type.document
+  module:
+    - file
+id: media.document.default
+targetEntityType: media
+bundle: document
+mode: default
+content:
+  field_media_document:
+    type: file_default
+    label: visually_hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 1
+    region: content
+hidden:
+  created: true
+  name: true
+  thumbnail: true
+  uid: true
diff --git a/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml b/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1f8eb004afe56c75a1b933196c953af3e97850f7
--- /dev/null
+++ b/core/recipes/document_media_type/config/core.entity_view_display.media.document.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.media.media_library
+    - field.field.media.document.field_media_document
+    - image.style.thumbnail
+    - media.type.document
+  module:
+    - image
+id: media.document.media_library
+targetEntityType: media
+bundle: document
+mode: media_library
+content:
+  thumbnail:
+    type: image
+    label: hidden
+    settings:
+      image_style: thumbnail
+      image_link: ''
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  created: true
+  field_media_document: true
+  name: true
+  uid: true
diff --git a/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml b/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fb0b9909e2e870210c3c927eb40e1de8f10947f2
--- /dev/null
+++ b/core/recipes/document_media_type/config/field.field.media.document.field_media_document.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.media.field_media_document
+    - media.type.document
+  module:
+    - file
+  enforced:
+    module:
+      - media
+id: media.document.field_media_document
+field_name: field_media_document
+entity_type: media
+bundle: document
+label: Document
+description: ''
+required: true
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  handler: 'default:file'
+  handler_settings: {  }
+  file_directory: '[date:custom:Y]-[date:custom:m]'
+  file_extensions: 'txt rtf doc docx ppt pptx xls xlsx pdf odf odg odp ods odt fodt fods fodp fodg key numbers pages'
+  max_filesize: ''
+  description_field: false
+field_type: file
diff --git a/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml b/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml
new file mode 100644
index 0000000000000000000000000000000000000000..309e509de09fd29015823f560dcd738f4866db47
--- /dev/null
+++ b/core/recipes/document_media_type/config/field.storage.media.field_media_document.yml
@@ -0,0 +1,25 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - file
+    - media
+  enforced:
+    module:
+      - media
+id: media.field_media_document
+field_name: field_media_document
+entity_type: media
+type: file
+settings:
+  target_type: file
+  display_field: false
+  display_default: false
+  uri_scheme: public
+module: file
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/document_media_type/config/media.type.document.yml b/core/recipes/document_media_type/config/media.type.document.yml
new file mode 100644
index 0000000000000000000000000000000000000000..35d7f1a60d57ca439eb9f6922d4c3323629635b1
--- /dev/null
+++ b/core/recipes/document_media_type/config/media.type.document.yml
@@ -0,0 +1,13 @@
+langcode: en
+status: true
+dependencies: {  }
+id: document
+label: Document
+description: 'An uploaded file or document, such as a PDF.'
+source: file
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+  source_field: field_media_document
+field_map:
+  name: name
diff --git a/core/recipes/document_media_type/recipe.yml b/core/recipes/document_media_type/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..868610953e0957def8d27fb376b7f1ea5d2a4f38
--- /dev/null
+++ b/core/recipes/document_media_type/recipe.yml
@@ -0,0 +1,25 @@
+name: 'Document media type'
+description: 'Provides "Document" media type and related configuration to enable uploaded files or documents, such as a PDF.'
+type: 'Media type'
+install:
+  - media_library
+  - path
+  - views
+config:
+  import:
+    file:
+      - views.view.files
+    media_library:
+      - core.entity_view_mode.media.media_library
+      - core.entity_form_mode.media.media_library
+      - image.style.media_library
+      - views.view.media_library
+    media:
+      - core.entity_view_mode.media.full
+      - system.action.media_delete_action
+      - system.action.media_publish_action
+      - system.action.media_save_action
+      - system.action.media_unpublish_action
+      - views.view.media
+    image:
+      - image.style.thumbnail
diff --git a/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml b/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e462b2ab956f59d54c1ec3f4b92f66aed50b8c76
--- /dev/null
+++ b/core/recipes/editorial_workflow/config/workflows.workflow.editorial.yml
@@ -0,0 +1,60 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - content_moderation
+id: editorial
+label: Editorial
+type: content_moderation
+type_settings:
+  states:
+    archived:
+      label: Archived
+      weight: 5
+      published: false
+      default_revision: true
+    draft:
+      label: Draft
+      weight: -5
+      published: false
+      default_revision: false
+    published:
+      label: Published
+      weight: 0
+      published: true
+      default_revision: true
+  transitions:
+    archive:
+      label: Archive
+      from:
+        - published
+      to: archived
+      weight: 2
+    archived_draft:
+      label: 'Restore to Draft'
+      from:
+        - archived
+      to: draft
+      weight: 3
+    archived_published:
+      label: Restore
+      from:
+        - archived
+      to: published
+      weight: 4
+    create_new_draft:
+      label: 'Create New Draft'
+      from:
+        - draft
+        - published
+      to: draft
+      weight: 0
+    publish:
+      label: Publish
+      from:
+        - draft
+        - published
+      to: published
+      weight: 1
+  entity_types: {  }
+  default_moderation_state: draft
diff --git a/core/recipes/editorial_workflow/recipe.yml b/core/recipes/editorial_workflow/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..baa7f86de7f2470da5889bcc4df73fce8e194d8e
--- /dev/null
+++ b/core/recipes/editorial_workflow/recipe.yml
@@ -0,0 +1,12 @@
+name: 'Editorial workflow'
+description: 'Provides an editorial workflow for moderating content.'
+type: 'Workflow'
+install:
+  - content_moderation
+  # The moderated_content view depends on Node.
+  - node
+  - views
+config:
+  import:
+    content_moderation:
+      - views.view.moderated_content
diff --git a/core/recipes/example/composer.json b/core/recipes/example/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..1d231ba7eec30462cd0f0e0a839f1457c92aed1c
--- /dev/null
+++ b/core/recipes/example/composer.json
@@ -0,0 +1,9 @@
+{
+  "name": "drupal_recipe/example",
+  "description": "An example Drupal recipe description",
+  "type": "drupal-recipe",
+  "require": {
+    "drupal/core": "^10.0.x-dev"
+  },
+  "license": "GPL-2.0-or-later"
+}
diff --git a/core/recipes/example/recipe.yml b/core/recipes/example/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6f53bebee39aacc5f06cc2e6e12f2a7d8a8db736
--- /dev/null
+++ b/core/recipes/example/recipe.yml
@@ -0,0 +1,46 @@
+# The type key is similar to the package key in module.info.yml. It
+# can be used by the UI to group Drupal recipes. Additionally,
+# the type 'Site' means that the Drupal recipe will be listed in
+# the installer.
+type: 'Content type'
+
+install:
+  # An array of modules or themes to install, if they are not already.
+  # The system will detect if it is a theme or a module. During the
+  # install only simple configuration from the new modules is created.
+  # This allows the Drupal recipe control over the configuration.
+  - node
+  - text
+
+config:
+  # A Drupal recipe can have a config directory. All configuration
+  # is this directory will be imported after the modules have been
+  # installed.
+
+  # Additionally, the Drupal recipe can install configuration entities
+  # provided by the modules it configures. This allows them to not have
+  # to maintain or copy this configuration. Note the examples below are
+  # fictitious.
+  import:
+    node:
+      - node.type.article
+    # Import all configuration that is provided by the text module and any
+    # optional configuration that depends on the text module that is provided by
+    # modules already installed.
+    text: '*'
+
+  # Configuration actions may be defined. The structure here should be
+  # entity_type.ID.action. Below the user role entity type with an ID of
+  # editor is having the permissions added. The permissions key will be
+  # mapped to the \Drupal\user\Entity\Role::grantPermission() method.
+  actions:
+    user.role.editor:
+      ensure_exists:
+        label: 'Editor'
+      grantPermissions:
+        - 'delete any article content'
+        - 'edit any article content'
+
+content: {}
+# A Drupal recipe can have a content directory. All content in this
+# directory will be created after the configuration is installed.
diff --git a/core/recipes/feedback_contact_form/config/contact.form.feedback.yml b/core/recipes/feedback_contact_form/config/contact.form.feedback.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e222ecd4bffa6dce2acb19120676a919878f0c09
--- /dev/null
+++ b/core/recipes/feedback_contact_form/config/contact.form.feedback.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies: {  }
+id: feedback
+label: 'Website feedback'
+recipients:
+  - admin@example.com
+reply: ''
+weight: 0
+message: 'Your message has been sent.'
+redirect: ''
diff --git a/core/recipes/feedback_contact_form/recipe.yml b/core/recipes/feedback_contact_form/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c6368c6cfe5670a1ed81f795fc5fd7a425879d42
--- /dev/null
+++ b/core/recipes/feedback_contact_form/recipe.yml
@@ -0,0 +1,24 @@
+name: 'Website feedback contact form'
+description: 'Provides a website feedback contact form.'
+type: 'Contact form'
+install:
+  - contact
+config:
+  import:
+    contact:
+      - contact.form.personal
+    system:
+      - system.menu.footer
+  actions:
+    core.menu.static_menu_link_overrides:
+      simple_config_update:
+        definitions.contact__site_page:
+          menu_name: footer
+          parent: ''
+          weight: 0
+          expanded: false
+          enabled: true
+    user.role.anonymous:
+      grantPermission: 'access site-wide contact form'
+    user.role.authenticated:
+      grantPermission: 'access site-wide contact form'
diff --git a/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml b/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e30fc15eaf3cfca53e4a554966604ddc570e7762
--- /dev/null
+++ b/core/recipes/full_html_format_editor/config/editor.editor.full_html.yml
@@ -0,0 +1,102 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - filter.format.full_html
+  module:
+    - ckeditor5
+format: full_html
+editor: ckeditor5
+settings:
+  toolbar:
+    items:
+      - bold
+      - italic
+      - strikethrough
+      - superscript
+      - subscript
+      - removeFormat
+      - '|'
+      - link
+      - '|'
+      - bulletedList
+      - numberedList
+      - '|'
+      - blockQuote
+      - drupalInsertImage
+      - insertTable
+      - horizontalLine
+      - '|'
+      - heading
+      - codeBlock
+      - '|'
+      - sourceEditing
+  plugins:
+    ckeditor5_codeBlock:
+      languages:
+        -
+          label: 'Plain text'
+          language: plaintext
+        -
+          label: C
+          language: c
+        -
+          label: 'C#'
+          language: cs
+        -
+          label: C++
+          language: cpp
+        -
+          label: CSS
+          language: css
+        -
+          label: Diff
+          language: diff
+        -
+          label: HTML
+          language: html
+        -
+          label: Java
+          language: java
+        -
+          label: JavaScript
+          language: javascript
+        -
+          label: PHP
+          language: php
+        -
+          label: Python
+          language: python
+        -
+          label: Ruby
+          language: ruby
+        -
+          label: TypeScript
+          language: typescript
+        -
+          label: XML
+          language: xml
+    ckeditor5_heading:
+      enabled_headings:
+        - heading2
+        - heading3
+        - heading4
+        - heading5
+        - heading6
+    ckeditor5_imageResize:
+      allow_resize: true
+    ckeditor5_list:
+      properties:
+        reversed: true
+        startIndex: true
+      multiBlock: true
+    ckeditor5_sourceEditing:
+      allowed_tags: {  }
+image_upload:
+  status: true
+  scheme: public
+  directory: inline-images
+  max_size: null
+  max_dimensions:
+    width: null
+    height: null
diff --git a/core/recipes/full_html_format_editor/config/filter.format.full_html.yml b/core/recipes/full_html_format_editor/config/filter.format.full_html.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a0e616a4989feaa028a3496ffd73357454191807
--- /dev/null
+++ b/core/recipes/full_html_format_editor/config/filter.format.full_html.yml
@@ -0,0 +1,41 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - editor
+name: 'Full HTML'
+format: full_html
+weight: 2
+roles:
+  - administrator
+filters:
+  editor_file_reference:
+    id: editor_file_reference
+    provider: editor
+    status: true
+    weight: 11
+    settings: {  }
+  filter_align:
+    id: filter_align
+    provider: filter
+    status: true
+    weight: 8
+    settings: {  }
+  filter_caption:
+    id: filter_caption
+    provider: filter
+    status: true
+    weight: 9
+    settings: {  }
+  filter_htmlcorrector:
+    id: filter_htmlcorrector
+    provider: filter
+    status: true
+    weight: 10
+    settings: {  }
+  filter_image_lazy_load:
+    id: filter_image_lazy_load
+    provider: filter
+    status: true
+    weight: 15
+    settings: {  }
diff --git a/core/recipes/full_html_format_editor/recipe.yml b/core/recipes/full_html_format_editor/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..171c75ac7b8188bca9eae26f2311c4e0f1d76549
--- /dev/null
+++ b/core/recipes/full_html_format_editor/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Full HTML editor'
+description: 'Provides "Full HTML" text format along with WYSIWYG editor and related configuration.'
+type: 'Text format editor'
+install:
+  - ckeditor5
diff --git a/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml b/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1d7a19b5932822e4ca23b9a874f28673a02861c7
--- /dev/null
+++ b/core/recipes/image_media_type/config/core.entity_form_display.media.image.default.yml
@@ -0,0 +1,54 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.image.field_media_image
+    - image.style.thumbnail
+    - media.type.image
+  module:
+    - image
+    - path
+id: media.image.default
+targetEntityType: media
+bundle: image
+mode: default
+content:
+  created:
+    type: datetime_timestamp
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_media_image:
+    type: image_image
+    weight: 0
+    region: content
+    settings:
+      progress_indicator: throbber
+      preview_image_style: thumbnail
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 30
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    weight: 100
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 5
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden:
+  name: true
diff --git a/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml b/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..63babd6f80d29c4bc5af130db3f4363a488e1773
--- /dev/null
+++ b/core/recipes/image_media_type/config/core.entity_form_display.media.image.media_library.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_form_mode.media.media_library
+    - field.field.media.image.field_media_image
+    - image.style.thumbnail
+    - media.type.image
+  module:
+    - image
+id: media.image.media_library
+targetEntityType: media
+bundle: image
+mode: media_library
+content:
+  field_media_image:
+    type: image_image
+    weight: 1
+    region: content
+    settings:
+      progress_indicator: throbber
+      preview_image_style: thumbnail
+    third_party_settings: {  }
+hidden:
+  created: true
+  name: true
+  path: true
+  status: true
+  uid: true
diff --git a/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml b/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e2b85e90ac2ae468baab41d4e532edea96dece2a
--- /dev/null
+++ b/core/recipes/image_media_type/config/core.entity_view_display.media.image.default.yml
@@ -0,0 +1,30 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.image.field_media_image
+    - image.style.large
+    - media.type.image
+  module:
+    - image
+id: media.image.default
+targetEntityType: media
+bundle: image
+mode: default
+content:
+  field_media_image:
+    type: image
+    label: visually_hidden
+    settings:
+      image_style: large
+      image_link: ''
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 1
+    region: content
+hidden:
+  created: true
+  name: true
+  thumbnail: true
+  uid: true
diff --git a/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml b/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..15469d4def11e4b0f6583f2f17fb9efe8d975013
--- /dev/null
+++ b/core/recipes/image_media_type/config/core.entity_view_display.media.image.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.media.media_library
+    - field.field.media.image.field_media_image
+    - image.style.medium
+    - media.type.image
+  module:
+    - image
+id: media.image.media_library
+targetEntityType: media
+bundle: image
+mode: media_library
+content:
+  thumbnail:
+    type: image
+    label: hidden
+    settings:
+      image_style: medium
+      image_link: ''
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  created: true
+  field_media_image: true
+  name: true
+  uid: true
diff --git a/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml b/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2e2cebf91fe0fcbd8c3024da71ad0e0fafa680bc
--- /dev/null
+++ b/core/recipes/image_media_type/config/field.field.media.image.field_media_image.yml
@@ -0,0 +1,40 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.media.field_media_image
+    - media.type.image
+  module:
+    - image
+  enforced:
+    module:
+      - media
+id: media.image.field_media_image
+field_name: field_media_image
+entity_type: media
+bundle: image
+label: Image
+description: ''
+required: true
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  handler: 'default:file'
+  handler_settings: {  }
+  file_directory: '[date:custom:Y]-[date:custom:m]'
+  file_extensions: 'png gif jpg jpeg webp'
+  max_filesize: ''
+  max_resolution: ''
+  min_resolution: ''
+  alt_field: true
+  alt_field_required: true
+  title_field: false
+  title_field_required: false
+  default_image:
+    uuid: null
+    alt: ''
+    title: ''
+    width: null
+    height: null
+field_type: image
diff --git a/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml b/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml
new file mode 100644
index 0000000000000000000000000000000000000000..59a6fbe14297a668552edd686b66388d5fd58d70
--- /dev/null
+++ b/core/recipes/image_media_type/config/field.storage.media.field_media_image.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - file
+    - image
+    - media
+  enforced:
+    module:
+      - media
+id: media.field_media_image
+field_name: field_media_image
+entity_type: media
+type: image
+settings:
+  target_type: file
+  display_field: false
+  display_default: false
+  uri_scheme: public
+  default_image:
+    uuid: null
+    alt: ''
+    title: ''
+    width: null
+    height: null
+module: image
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/image_media_type/config/media.type.image.yml b/core/recipes/image_media_type/config/media.type.image.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b92ef428c3a21c7dd8589a607eb49febd0b16ecc
--- /dev/null
+++ b/core/recipes/image_media_type/config/media.type.image.yml
@@ -0,0 +1,13 @@
+langcode: en
+status: true
+dependencies: {  }
+id: image
+label: Image
+description: 'Use local images for reusable media.'
+source: image
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+  source_field: field_media_image
+field_map:
+  name: name
diff --git a/core/recipes/image_media_type/recipe.yml b/core/recipes/image_media_type/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9fe2163049457488c3cb853e13b0a384cb07b38d
--- /dev/null
+++ b/core/recipes/image_media_type/recipe.yml
@@ -0,0 +1,27 @@
+name: 'Image media type'
+description: 'Provides "Image" media type and related configuration. Use local images for reusable media.'
+type: 'Media type'
+install:
+  - media_library
+  - path
+  - views
+config:
+  import:
+    file:
+      - views.view.files
+    media_library:
+      - core.entity_view_mode.media.media_library
+      - core.entity_form_mode.media.media_library
+      - image.style.media_library
+      - views.view.media_library
+    media:
+      - core.entity_view_mode.media.full
+      - system.action.media_delete_action
+      - system.action.media_publish_action
+      - system.action.media_save_action
+      - system.action.media_unpublish_action
+      - views.view.media
+    image:
+      - image.style.medium
+      - image.style.large
+      - image.style.thumbnail
diff --git a/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e3fdffe0dab7a4d19f597ddc09b93a804494de7c
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.default.yml
@@ -0,0 +1,52 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.video.field_media_video_file
+    - media.type.video
+  module:
+    - file
+    - path
+id: media.video.default
+targetEntityType: media
+bundle: video
+mode: default
+content:
+  created:
+    type: datetime_timestamp
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_media_video_file:
+    type: file_generic
+    weight: 0
+    region: content
+    settings:
+      progress_indicator: throbber
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 30
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    weight: 100
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 5
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden:
+  name: true
diff --git a/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..db2cf0b19e98506e3b1b54f4185412ccd1e0f51a
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/core.entity_form_display.media.video.media_library.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_form_mode.media.media_library
+    - field.field.media.video.field_media_video_file
+    - media.type.video
+id: media.video.media_library
+targetEntityType: media
+bundle: video
+mode: media_library
+content: {  }
+hidden:
+  created: true
+  field_media_video_file: true
+  name: true
+  path: true
+  status: true
+  uid: true
diff --git a/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3c26f17aae3e9a4dd263a008233424f1bd06ad13
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.default.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.video.field_media_video_file
+    - media.type.video
+  module:
+    - file
+id: media.video.default
+targetEntityType: media
+bundle: video
+mode: default
+content:
+  field_media_video_file:
+    type: file_video
+    label: visually_hidden
+    settings:
+      controls: true
+      autoplay: false
+      loop: false
+      multiple_file_display_type: tags
+      muted: false
+      width: 640
+      height: 480
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  created: true
+  name: true
+  thumbnail: true
+  uid: true
diff --git a/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a4db1a99b8e9d156375f405075a0041297a52200
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/core.entity_view_display.media.video.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.media.media_library
+    - field.field.media.video.field_media_video_file
+    - image.style.thumbnail
+    - media.type.video
+  module:
+    - image
+id: media.video.media_library
+targetEntityType: media
+bundle: video
+mode: media_library
+content:
+  thumbnail:
+    type: image
+    label: hidden
+    settings:
+      image_style: thumbnail
+      image_link: ''
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  created: true
+  field_media_video_file: true
+  name: true
+  uid: true
diff --git a/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml b/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b6c0be146e45a376e680c704c72391f5bbd55eca
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/field.field.media.video.field_media_video_file.yml
@@ -0,0 +1,26 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.media.field_media_video_file
+    - media.type.video
+  module:
+    - file
+id: media.video.field_media_video_file
+field_name: field_media_video_file
+entity_type: media
+bundle: video
+label: 'Video file'
+description: ''
+required: true
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  handler: 'default:file'
+  handler_settings: {  }
+  file_directory: '[date:custom:Y]-[date:custom:m]'
+  file_extensions: mp4
+  max_filesize: ''
+  description_field: false
+field_type: file
diff --git a/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml b/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0ac96a14b7c2375193f151de5773e9c707e7bdcf
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/field.storage.media.field_media_video_file.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - file
+    - media
+id: media.field_media_video_file
+field_name: field_media_video_file
+entity_type: media
+type: file
+settings:
+  target_type: file
+  display_field: false
+  display_default: false
+  uri_scheme: public
+module: file
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/local_video_media_type/config/media.type.video.yml b/core/recipes/local_video_media_type/config/media.type.video.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b07ac27059a97f9e4ff21b6bbf78016e6c2154f8
--- /dev/null
+++ b/core/recipes/local_video_media_type/config/media.type.video.yml
@@ -0,0 +1,13 @@
+langcode: en
+status: true
+dependencies: {  }
+id: video
+label: Video
+description: 'A locally hosted video file.'
+source: video_file
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+  source_field: field_media_video_file
+field_map:
+  name: name
diff --git a/core/recipes/local_video_media_type/recipe.yml b/core/recipes/local_video_media_type/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8bb1c6f6f630d7fe729dd8d885cf53f7eac01b8c
--- /dev/null
+++ b/core/recipes/local_video_media_type/recipe.yml
@@ -0,0 +1,25 @@
+name: 'Local video media'
+description: 'Provides a media type for self-hosted video files.'
+type: 'Media type'
+install:
+  - media_library
+  - path
+  - views
+config:
+  import:
+    file:
+      - views.view.files
+    media_library:
+      - core.entity_view_mode.media.media_library
+      - core.entity_form_mode.media.media_library
+      - image.style.media_library
+      - views.view.media_library
+    media:
+      - core.entity_view_mode.media.full
+      - system.action.media_delete_action
+      - system.action.media_publish_action
+      - system.action.media_save_action
+      - system.action.media_unpublish_action
+      - views.view.media
+    image:
+      - image.style.thumbnail
diff --git a/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml b/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml
new file mode 100644
index 0000000000000000000000000000000000000000..27226a1a6f263dc53df1994a2eff3b2a43d7ac21
--- /dev/null
+++ b/core/recipes/page_content_type/config/core.base_field_override.node.page.promote.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - node.type.page
+id: node.page.promote
+field_name: promote
+entity_type: node
+bundle: page
+label: 'Promoted to front page'
+description: ''
+required: false
+translatable: false
+default_value:
+  -
+    value: 0
+default_value_callback: ''
+settings:
+  on_label: 'On'
+  off_label: 'Off'
+field_type: boolean
diff --git a/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml b/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..edb853ed3de0c5fcef565eaeb2f5dd266fae50c8
--- /dev/null
+++ b/core/recipes/page_content_type/config/core.entity_form_display.node.page.default.yml
@@ -0,0 +1,76 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.node.page.body
+    - node.type.page
+  module:
+    - path
+    - text
+id: node.page.default
+targetEntityType: node
+bundle: page
+mode: default
+content:
+  body:
+    type: text_textarea_with_summary
+    weight: 31
+    region: content
+    settings:
+      rows: 9
+      summary_rows: 3
+      placeholder: ''
+      show_summary: false
+    third_party_settings: {  }
+  created:
+    type: datetime_timestamp
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 30
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  promote:
+    type: boolean_checkbox
+    weight: 15
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    weight: 120
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  sticky:
+    type: boolean_checkbox
+    weight: 16
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  title:
+    type: string_textfield
+    weight: -5
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 5
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden: {  }
diff --git a/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml b/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bd70482cd1da447dc2a938c29ebb2a438dac321e
--- /dev/null
+++ b/core/recipes/page_content_type/config/core.entity_view_display.node.page.default.yml
@@ -0,0 +1,25 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.node.page.body
+    - node.type.page
+  module:
+    - text
+    - user
+id: node.page.default
+targetEntityType: node
+bundle: page
+mode: default
+content:
+  body:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 100
+    region: content
+  links:
+    weight: 101
+    region: content
+hidden: {  }
diff --git a/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml b/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml
new file mode 100644
index 0000000000000000000000000000000000000000..34a70d932faa89e65454018070db50a6df23a8d2
--- /dev/null
+++ b/core/recipes/page_content_type/config/core.entity_view_display.node.page.teaser.yml
@@ -0,0 +1,27 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.node.teaser
+    - field.field.node.page.body
+    - node.type.page
+  module:
+    - text
+    - user
+id: node.page.teaser
+targetEntityType: node
+bundle: page
+mode: teaser
+content:
+  body:
+    type: text_summary_or_trimmed
+    label: hidden
+    settings:
+      trim_length: 600
+    third_party_settings: {  }
+    weight: 100
+    region: content
+  links:
+    weight: 101
+    region: content
+hidden: {  }
diff --git a/core/recipes/page_content_type/config/field.field.node.page.body.yml b/core/recipes/page_content_type/config/field.field.node.page.body.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4ff17d0e711a22307564a3a19fb04549ee779fb2
--- /dev/null
+++ b/core/recipes/page_content_type/config/field.field.node.page.body.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.node.body
+    - node.type.page
+  module:
+    - text
+id: node.page.body
+field_name: body
+entity_type: node
+bundle: page
+label: Body
+description: ''
+required: false
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  display_summary: true
+  required_summary: false
+field_type: text_with_summary
diff --git a/core/recipes/page_content_type/config/node.type.page.yml b/core/recipes/page_content_type/config/node.type.page.yml
new file mode 100644
index 0000000000000000000000000000000000000000..755e8ed8ce5be5a015b5f34b80d10d332e0976b0
--- /dev/null
+++ b/core/recipes/page_content_type/config/node.type.page.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies: {  }
+name: 'Basic page'
+type: page
+description: "Use <em>basic pages</em> for your static content, such as an 'About us' page."
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: false
diff --git a/core/recipes/page_content_type/recipe.yml b/core/recipes/page_content_type/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e9193c024f2765e1d006aa74e166c5a58d2e2ec7
--- /dev/null
+++ b/core/recipes/page_content_type/recipe.yml
@@ -0,0 +1,21 @@
+name: 'Basic page'
+description: "Provides Basic page content type and related configuration. Use <em>basic pages</em> for your static content, such as an 'About us' page."
+type: 'Content type'
+install:
+  - node
+  - path
+config:
+  import:
+    node:
+      - core.entity_view_mode.node.full
+      - core.entity_view_mode.node.rss
+      - core.entity_view_mode.node.teaser
+      - field.storage.node.body
+      - system.action.node_delete_action
+      - system.action.node_make_sticky_action
+      - system.action.node_make_unsticky_action
+      - system.action.node_promote_action
+      - system.action.node_publish_action
+      - system.action.node_save_action
+      - system.action.node_unpromote_action
+      - system.action.node_unpublish_action
diff --git a/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0f57855dd0a453b9c1c52ffa74b8c461a180c5e0
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.default.yml
@@ -0,0 +1,53 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.remote_video.field_media_oembed_video
+    - media.type.remote_video
+  module:
+    - media
+    - path
+id: media.remote_video.default
+targetEntityType: media
+bundle: remote_video
+mode: default
+content:
+  created:
+    type: datetime_timestamp
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_media_oembed_video:
+    type: oembed_textfield
+    weight: 0
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 30
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    weight: 100
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 5
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden:
+  name: true
diff --git a/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6a1461cded73a6724ff6673fc2fe41f33d3cbc05
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/core.entity_form_display.media.remote_video.media_library.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_form_mode.media.media_library
+    - field.field.media.remote_video.field_media_oembed_video
+    - media.type.remote_video
+id: media.remote_video.media_library
+targetEntityType: media
+bundle: remote_video
+mode: media_library
+content: {  }
+hidden:
+  created: true
+  field_media_oembed_video: true
+  name: true
+  path: true
+  status: true
+  uid: true
diff --git a/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5dd5a52d6cc28c832307fcafdc33f218fb10f62f
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.default.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.remote_video.field_media_oembed_video
+    - media.type.remote_video
+  module:
+    - media
+id: media.remote_video.default
+targetEntityType: media
+bundle: remote_video
+mode: default
+content:
+  field_media_oembed_video:
+    type: oembed
+    label: hidden
+    settings:
+      max_width: 0
+      max_height: 0
+      loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  created: true
+  name: true
+  thumbnail: true
+  uid: true
diff --git a/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml
new file mode 100644
index 0000000000000000000000000000000000000000..268b1b37fe0918498e045b92894a312408f3b1e2
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/core.entity_view_display.media.remote_video.media_library.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.media.media_library
+    - field.field.media.remote_video.field_media_oembed_video
+    - image.style.medium
+    - media.type.remote_video
+  module:
+    - image
+id: media.remote_video.media_library
+targetEntityType: media
+bundle: remote_video
+mode: media_library
+content:
+  thumbnail:
+    type: image
+    label: hidden
+    settings:
+      image_style: medium
+      image_link: ''
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  created: true
+  field_media_oembed_video: true
+  name: true
+  uid: true
diff --git a/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml b/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6ff378fa17a10995c6880da15b2bac564a6d747b
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/field.field.media.remote_video.field_media_oembed_video.yml
@@ -0,0 +1,18 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.media.field_media_oembed_video
+    - media.type.remote_video
+id: media.remote_video.field_media_oembed_video
+field_name: field_media_oembed_video
+entity_type: media
+bundle: remote_video
+label: 'Video URL'
+description: ''
+required: true
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings: {  }
+field_type: string
diff --git a/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml b/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e8664f0b181c38b770d06841346ea1d7c2f1fea9
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/field.storage.media.field_media_oembed_video.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - media
+id: media.field_media_oembed_video
+field_name: field_media_oembed_video
+entity_type: media
+type: string
+settings:
+  max_length: 255
+  case_sensitive: false
+  is_ascii: false
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/remote_video_media_type/config/media.type.remote_video.yml b/core/recipes/remote_video_media_type/config/media.type.remote_video.yml
new file mode 100644
index 0000000000000000000000000000000000000000..203d69832765562befb5851a4fe374b3cd9e64bf
--- /dev/null
+++ b/core/recipes/remote_video_media_type/config/media.type.remote_video.yml
@@ -0,0 +1,17 @@
+langcode: en
+status: true
+dependencies: {  }
+id: remote_video
+label: 'Remote video'
+description: 'A remotely hosted video from YouTube or Vimeo.'
+source: 'oembed:video'
+queue_thumbnail_downloads: false
+new_revision: true
+source_configuration:
+  source_field: field_media_oembed_video
+  thumbnails_directory: 'public://oembed_thumbnails/[date:custom:Y-m]'
+  providers:
+    - YouTube
+    - Vimeo
+field_map:
+  title: name
diff --git a/core/recipes/remote_video_media_type/recipe.yml b/core/recipes/remote_video_media_type/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1f66ebfc199689dfcc17273e30ee996f9b01259b
--- /dev/null
+++ b/core/recipes/remote_video_media_type/recipe.yml
@@ -0,0 +1,23 @@
+name: 'Remote video media'
+description: 'Provides a media type for videos hosted on YouTube and Vimeo.'
+type: 'Media type'
+install:
+  - media_library
+  - path
+  - views
+config:
+  import:
+    media_library:
+      - core.entity_view_mode.media.media_library
+      - core.entity_form_mode.media.media_library
+      - image.style.media_library
+      - views.view.media_library
+    media:
+      - core.entity_view_mode.media.full
+      - system.action.media_delete_action
+      - system.action.media_publish_action
+      - system.action.media_save_action
+      - system.action.media_unpublish_action
+      - views.view.media
+    image:
+      - image.style.medium
diff --git a/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml b/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5656b14514820ac7890382f41d131338663371d4
--- /dev/null
+++ b/core/recipes/restricted_html_format/config/filter.format.restricted_html.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies: {  }
+name: 'Restricted HTML'
+format: restricted_html
+weight: 1
+filters:
+  filter_html:
+    id: filter_html
+    provider: filter
+    status: true
+    weight: -10
+    settings:
+      allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>'
+      filter_html_help: true
+      filter_html_nofollow: false
+  filter_autop:
+    id: filter_autop
+    provider: filter
+    status: true
+    weight: 0
+    settings: {  }
+  filter_url:
+    id: filter_url
+    provider: filter
+    status: true
+    weight: 0
+    settings:
+      filter_url_length: 72
diff --git a/core/recipes/restricted_html_format/recipe.yml b/core/recipes/restricted_html_format/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8aec764918f93e9d44a5735a61778b5e1d8b6b5c
--- /dev/null
+++ b/core/recipes/restricted_html_format/recipe.yml
@@ -0,0 +1,11 @@
+name: 'Restricted HTML'
+description: 'Provides "Restricted HTML" text format.'
+type: 'Text format'
+install:
+  - filter
+config:
+  import:
+    filter: '*'
+  actions:
+    user.role.anonymous:
+      grantPermission: 'use text format restricted_html'
diff --git a/core/recipes/standard/config/user.role.administrator.yml b/core/recipes/standard/config/user.role.administrator.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ca48a58b4eed312dd87bfbeca5cd6ba01367171d
--- /dev/null
+++ b/core/recipes/standard/config/user.role.administrator.yml
@@ -0,0 +1,8 @@
+langcode: en
+status: true
+dependencies: {  }
+id: administrator
+label: Administrator
+weight: 3
+is_admin: true
+permissions: {  }
diff --git a/core/recipes/standard/config/user.role.content_editor.yml b/core/recipes/standard/config/user.role.content_editor.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b1dbe10170a18acda742419e8de469568c2b57f9
--- /dev/null
+++ b/core/recipes/standard/config/user.role.content_editor.yml
@@ -0,0 +1,23 @@
+langcode: en
+status: true
+dependencies: {  }
+id: content_editor
+label: 'Content editor'
+weight: 2
+is_admin: false
+permissions:
+  - 'access administration pages'
+  - 'access content overview'
+  - 'access contextual links'
+  - 'access files overview'
+  - 'access toolbar'
+  - 'administer url aliases'
+  - 'create terms in tags'
+  - 'create url aliases'
+  - 'edit own comments'
+  - 'edit terms in tags'
+  - 'delete own files'
+  - 'revert all revisions'
+  - 'view all revisions'
+  - 'view own unpublished content'
+  - 'view the administration theme'
diff --git a/core/recipes/standard/recipe.yml b/core/recipes/standard/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ae577ff8a905900315924be30fa0ac4531fd213d
--- /dev/null
+++ b/core/recipes/standard/recipe.yml
@@ -0,0 +1,85 @@
+name: 'Standard'
+description: 'Provides a standard site with commonly used features pre-configured.'
+type: 'Site'
+recipes:
+  - basic_block_type
+  - basic_shortcuts
+  - article_comment
+  - article_tags
+  - feedback_contact_form
+  - article_content_type
+  - page_content_type
+  - basic_html_format_editor
+  - full_html_format_editor
+  - content_search
+  - core_recommended_performance
+  - core_recommended_maintenance
+  - core_recommended_admin_theme
+  - core_recommended_front_end_theme
+  - user_picture
+  # Provides a fallback text format which is available to all users.
+  - restricted_html_format
+install:
+  - image
+  - help
+  - history
+  - config
+  - contextual
+  - menu_link_content
+  - datetime
+  - menu_ui
+  - options
+  - toolbar
+  - field_ui
+  - views_ui
+  - shortcut
+config:
+  import:
+    claro:
+      - block.block.claro_help
+    help:
+      - search.page.help_search
+      - block.block.claro_help_search
+    image:
+      - image.style.large
+      - image.style.thumbnail
+    node:
+      - views.view.archive
+      - views.view.content
+      - views.view.content_recent
+      - views.view.frontpage
+      - views.view.glossary
+    olivero:
+      - block.block.olivero_help
+      - block.block.olivero_search_form_narrow
+      - block.block.olivero_search_form_wide
+      - block.block.olivero_syndicate
+    user:
+      - core.entity_view_mode.user.compact
+      - search.page.user_search
+      - views.view.user_admin_people
+      - views.view.who_s_new
+      - views.view.who_s_online
+  actions:
+    node.settings:
+      simple_config_update:
+        use_admin_theme: true
+    system.site:
+      simple_config_update:
+        page.front: /node
+    user.role.authenticated:
+      grantPermission: 'delete own files'
+    user.role.content_editor:
+      grantPermissionsForEachNodeType:
+        - 'create %bundle content'
+        - 'delete %bundle revisions'
+        - 'delete own %bundle content'
+        - 'edit own %bundle content'
+    user.role.anonymous:
+      # This recipe assumes all published content should be publicly accessible.
+      grantPermission: 'access content'
+    user.settings:
+      simple_config_update:
+        verify_mail: true
+        register: visitors_admin_approval
+        cancel_method: user_cancel_block
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fde3282498d0d450699629709a37990b404c517c
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml
@@ -0,0 +1,24 @@
+langcode: en
+dependencies:
+  module:
+    - responsive_image
+  enforced:
+    module:
+      - responsive_image
+name: max_1300x1300
+label: 'Max 1300x1300'
+effects:
+  04caae9a-fa3e-4ea6-ae09-9c26aec7d308:
+    uuid: 04caae9a-fa3e-4ea6-ae09-9c26aec7d308
+    id: image_scale
+    weight: 1
+    data:
+      width: 1300
+      height: 1300
+      upscale: false
+  e8c9d6ba-a017-4a87-9999-7ce52e138e1d:
+    uuid: e8c9d6ba-a017-4a87-9999-7ce52e138e1d
+    id: image_convert
+    weight: 2
+    data:
+      extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a63e72ab6f358c094919bea2b540b947d54d7c38
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml
@@ -0,0 +1,24 @@
+langcode: en
+dependencies:
+  module:
+    - responsive_image
+  enforced:
+    module:
+      - responsive_image
+name: max_2600x2600
+label: 'Max 2600x2600'
+effects:
+  9b311dd1-0351-45a1-9500-cd069e4670cb:
+    uuid: 9b311dd1-0351-45a1-9500-cd069e4670cb
+    id: image_scale
+    weight: 1
+    data:
+      width: 2600
+      height: 2600
+      upscale: false
+  3c42f186-7beb-4dbf-b720-bff9dfeaa677:
+    uuid: 3c42f186-7beb-4dbf-b720-bff9dfeaa677
+    id: image_convert
+    weight: 2
+    data:
+      extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e820c8bb01dad1f36e695d18de27ae421d712b05
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml
@@ -0,0 +1,24 @@
+langcode: en
+dependencies:
+  module:
+    - responsive_image
+  enforced:
+    module:
+      - responsive_image
+name: max_325x325
+label: 'Max 325x325'
+effects:
+  cb842cc8-682f-42a6-bd05-5a1ac726f0d8:
+    uuid: cb842cc8-682f-42a6-bd05-5a1ac726f0d8
+    id: image_scale
+    weight: 1
+    data:
+      width: 325
+      height: 325
+      upscale: false
+  f2b6c795-26ae-4130-aa18-aa120ea3ba98:
+    uuid: f2b6c795-26ae-4130-aa18-aa120ea3ba98
+    id: image_convert
+    weight: 2
+    data:
+      extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d5beda6259f3bfd3e842a31e8b914b5ad1d176e1
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml
@@ -0,0 +1,24 @@
+langcode: en
+dependencies:
+  module:
+    - responsive_image
+  enforced:
+    module:
+      - responsive_image
+name: max_650x650
+label: 'Max 650x650'
+effects:
+  949c201a-77f5-48f6-ba00-be91eb1aad47:
+    uuid: 949c201a-77f5-48f6-ba00-be91eb1aad47
+    id: image_scale
+    weight: 1
+    data:
+      width: 650
+      height: 650
+      upscale: false
+  4a2a7af8-8ea3-419d-b5f8-256d57016102:
+    uuid: 4a2a7af8-8ea3-419d-b5f8-256d57016102
+    id: image_convert
+    weight: 2
+    data:
+      extension: webp
diff --git a/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml b/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml
new file mode 100644
index 0000000000000000000000000000000000000000..51590cd7b205221aa8e87f18808c64e2dd539911
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/responsive_image.styles.narrow.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - image.style.max_1300x1300
+    - image.style.max_325x325
+    - image.style.max_650x650
+id: narrow
+label: Narrow
+image_style_mappings:
+  -
+    image_mapping_type: sizes
+    image_mapping:
+      sizes: '(min-width: 1290px) 325px, (min-width: 851px) 25vw, (min-width: 560px) 50vw, 100vw'
+      sizes_image_styles:
+        - max_1300x1300
+        - max_650x650
+        - max_325x325
+    breakpoint_id: responsive_image.viewport_sizing
+    multiplier: 1x
+breakpoint_group: responsive_image
+fallback_image_style: max_325x325
diff --git a/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml b/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml
new file mode 100644
index 0000000000000000000000000000000000000000..06cb8a98e80599b57f739fbd8bf7e92959899987
--- /dev/null
+++ b/core/recipes/standard_responsive_images/config/responsive_image.styles.wide.yml
@@ -0,0 +1,24 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - image.style.max_1300x1300
+    - image.style.max_2600x2600
+    - image.style.max_325x325
+    - image.style.max_650x650
+id: wide
+label: Wide
+image_style_mappings:
+  -
+    image_mapping_type: sizes
+    image_mapping:
+      sizes: '(min-width: 1290px) 1290px, 100vw'
+      sizes_image_styles:
+        - max_2600x2600
+        - max_1300x1300
+        - max_650x650
+        - max_325x325
+    breakpoint_id: responsive_image.viewport_sizing
+    multiplier: 1x
+breakpoint_group: responsive_image
+fallback_image_style: max_325x325
diff --git a/core/recipes/standard_responsive_images/recipe.yml b/core/recipes/standard_responsive_images/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..210a3286908c5083f3c7661a700df8aadc611345
--- /dev/null
+++ b/core/recipes/standard_responsive_images/recipe.yml
@@ -0,0 +1,8 @@
+name: 'Standard Responsive Images'
+description: 'Provides basic responsive images and accompanying image styles.'
+type: 'Media'
+install:
+  - responsive_image
+config:
+  import:
+    image: '*'
diff --git a/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml b/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4c754e86c71598ec2002718b7568e91d413557ea
--- /dev/null
+++ b/core/recipes/tags_taxonomy/config/taxonomy.vocabulary.tags.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+name: Tags
+vid: tags
+description: 'Use tags to group articles on similar topics into categories.'
+weight: 0
diff --git a/core/recipes/tags_taxonomy/recipe.yml b/core/recipes/tags_taxonomy/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..46436718a889804abfbf60bdc3783c9d89bd4999
--- /dev/null
+++ b/core/recipes/tags_taxonomy/recipe.yml
@@ -0,0 +1,11 @@
+name: Tags
+description: 'Provides "Tags" taxonomy vocabulary and related configuration. Use tags to group content on similar topics into categories.'
+type: 'Taxonomy'
+install:
+  - taxonomy
+config:
+  import:
+    taxonomy:
+      - core.entity_view_mode.taxonomy_term.full
+      - system.action.taxonomy_term_publish_action
+      - system.action.taxonomy_term_unpublish_action
diff --git a/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml b/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8098d4688a6d08b86f143bf9f85e55ec6f4abb87
--- /dev/null
+++ b/core/recipes/user_picture/config/core.entity_form_display.user.user.default.yml
@@ -0,0 +1,35 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.user.user.user_picture
+    - image.style.thumbnail
+  module:
+    - image
+    - user
+id: user.user.default
+targetEntityType: user
+bundle: user
+mode: default
+content:
+  account:
+    weight: -10
+    region: content
+  contact:
+    weight: 5
+    region: content
+  language:
+    weight: 0
+    region: content
+  timezone:
+    weight: 6
+    region: content
+  user_picture:
+    type: image_image
+    weight: -1
+    region: content
+    settings:
+      progress_indicator: throbber
+      preview_image_style: thumbnail
+    third_party_settings: {  }
+hidden: {  }
diff --git a/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml b/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1e0ea7c9f7e2ab26ceb56e038aaf5adea88bfcff
--- /dev/null
+++ b/core/recipes/user_picture/config/core.entity_view_display.user.user.compact.yml
@@ -0,0 +1,28 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.user.compact
+    - field.field.user.user.user_picture
+    - image.style.thumbnail
+  module:
+    - image
+    - user
+id: user.user.compact
+targetEntityType: user
+bundle: user
+mode: compact
+content:
+  user_picture:
+    type: image
+    label: hidden
+    settings:
+      image_style: thumbnail
+      image_link: content
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  member_for: true
diff --git a/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml b/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9bc86dc8906a46cd563aa76e986573ac0db82261
--- /dev/null
+++ b/core/recipes/user_picture/config/core.entity_view_display.user.user.default.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.user.user.user_picture
+    - image.style.thumbnail
+  module:
+    - image
+    - user
+id: user.user.default
+targetEntityType: user
+bundle: user
+mode: default
+content:
+  member_for:
+    weight: 5
+    region: content
+  user_picture:
+    type: image
+    label: hidden
+    settings:
+      image_style: thumbnail
+      image_link: content
+      image_loading:
+        attribute: lazy
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden: {  }
diff --git a/core/recipes/user_picture/config/field.field.user.user.user_picture.yml b/core/recipes/user_picture/config/field.field.user.user.user_picture.yml
new file mode 100644
index 0000000000000000000000000000000000000000..54a59c087f89146ed57d363f9d1cd54030486dd9
--- /dev/null
+++ b/core/recipes/user_picture/config/field.field.user.user.user_picture.yml
@@ -0,0 +1,37 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.user.user_picture
+  module:
+    - image
+    - user
+id: user.user.user_picture
+field_name: user_picture
+entity_type: user
+bundle: user
+label: Picture
+description: 'Your virtual face or picture.'
+required: false
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  handler: 'default:file'
+  handler_settings: {  }
+  file_directory: 'pictures/[date:custom:Y]-[date:custom:m]'
+  file_extensions: 'png gif jpg jpeg webp'
+  max_filesize: ''
+  max_resolution: ''
+  min_resolution: ''
+  alt_field: false
+  alt_field_required: false
+  title_field: false
+  title_field_required: false
+  default_image:
+    uuid: null
+    alt: ''
+    title: ''
+    width: null
+    height: null
+field_type: image
diff --git a/core/recipes/user_picture/config/field.storage.user.user_picture.yml b/core/recipes/user_picture/config/field.storage.user.user_picture.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6d0476df6c2049d9876079a71f80482478b01bb8
--- /dev/null
+++ b/core/recipes/user_picture/config/field.storage.user.user_picture.yml
@@ -0,0 +1,31 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - file
+    - image
+    - user
+id: user.user_picture
+field_name: user_picture
+entity_type: user
+type: image
+settings:
+  target_type: file
+  display_field: false
+  display_default: false
+  uri_scheme: public
+  default_image:
+    uuid: null
+    alt: ''
+    title: ''
+    width: null
+    height: null
+module: image
+locked: false
+cardinality: 1
+translatable: true
+indexes:
+  target_id:
+    - target_id
+persist_with_no_fields: false
+custom_storage: false
diff --git a/core/recipes/user_picture/recipe.yml b/core/recipes/user_picture/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ba84c830020cbbd058a36fe4a831ff5c197ea23e
--- /dev/null
+++ b/core/recipes/user_picture/recipe.yml
@@ -0,0 +1,8 @@
+name: User pictures
+description: 'Adds the ability for user accounts to have pictures (avatars).'
+type: Users
+install:
+  - field
+  - file
+  - image
+  - user
diff --git a/core/scripts/drupal b/core/scripts/drupal
index 891d5b8117800cfbe424864f7488f9c829b174ce..0c9eb300cde9cc6c99d7f986d141ddf0c3bdfeb7 100644
--- a/core/scripts/drupal
+++ b/core/scripts/drupal
@@ -10,6 +10,7 @@ use Drupal\Core\Command\GenerateTheme;
 use Drupal\Core\Command\QuickStartCommand;
 use Drupal\Core\Command\InstallCommand;
 use Drupal\Core\Command\ServerCommand;
+use Drupal\Core\Recipe\RecipeCommand;
 use Symfony\Component\Console\Application;
 
 if (PHP_SAPI !== 'cli') {
@@ -24,5 +25,6 @@ $application->add(new QuickStartCommand());
 $application->add(new InstallCommand($classloader));
 $application->add(new ServerCommand($classloader));
 $application->add(new GenerateTheme());
+$application->add(new RecipeCommand($classloader));
 
 $application->run();
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f1efd741390f1ecbef76e6b231afb2b5b18dfd15
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/CoreRecipesTest.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\Tests\BrowserTestBase;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * Tests applying all core-provided recipes on top of the Empty profile.
+ *
+ * @group Recipe
+ */
+class CoreRecipesTest extends BrowserTestBase {
+
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $profile = 'minimal';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * The data provider for apply recipe test.
+   *
+   * @return iterable<array<string>>
+   *   An iterable containing paths to recipe files.
+   */
+  public static function providerApplyRecipe(): iterable {
+    $finder = Finder::create()
+      ->in([
+        static::getDrupalRoot() . '/core/recipes',
+      ])
+      ->directories()
+      // Recipes can't contain other recipes, so we don't need to search in
+      // subdirectories.
+      ->depth(0)
+      // The Example recipe is for documentation only, and cannot be applied.
+      ->notName(['example']);
+
+    $scenarios = [];
+    /** @var \Symfony\Component\Finder\SplFileInfo $recipe */
+    foreach ($finder as $recipe) {
+      $name = $recipe->getBasename();
+      $scenarios[$name] = [
+        $recipe->getPathname(),
+      ];
+    }
+    return $scenarios;
+  }
+
+  /**
+   * Test the recipe apply.
+   *
+   * @param string $path
+   *   The path to the recipe file.
+   *
+   * @dataProvider providerApplyRecipe
+   */
+  public function testApplyRecipe(string $path): void {
+    $this->setUpCurrentUser(admin: TRUE);
+    $this->applyRecipe($path);
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..39149729e78e2e08b6e2e7feb8031fc2a20c3087
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeCommand
+ * @group Recipe
+ *
+ * BrowserTestBase is used for a proper Drupal install.
+ */
+class RecipeCommandTest extends BrowserTestBase {
+
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   *
+   * Disable strict config schema because this test explicitly makes the
+   * recipe system save invalid config, to prove that it validates it after
+   * the fact and raises an error.
+   */
+  protected $strictConfigSchema = FALSE;
+
+  public function testRecipeCommand(): void {
+    $this->assertFalse(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is not installed');
+    $this->assertCheckpointsExist([]);
+
+    $process = $this->applyRecipe('core/tests/fixtures/recipes/install_node_with_config');
+    $this->assertSame(0, $process->getExitCode());
+    $this->assertStringContainsString("Applied Install node with config recipe.", $process->getErrorOutput());
+    $this->assertStringContainsString('Install node with config applied successfully', $process->getOutput());
+    $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
+    $this->assertCheckpointsExist(["Backup before the 'Install node with config' recipe."]);
+
+    // Ensure recipes can be applied without affecting pre-existing checkpoints.
+    $process = $this->applyRecipe('core/tests/fixtures/recipes/install_two_modules');
+    $this->assertSame(0, $process->getExitCode());
+    $this->assertStringContainsString("Applied Install two modules recipe.", $process->getErrorOutput());
+    $this->assertStringContainsString('Install two modules applied successfully', $process->getOutput());
+    $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
+    $this->assertCheckpointsExist([
+      "Backup before the 'Install node with config' recipe.",
+      "Backup before the 'Install two modules' recipe.",
+    ]);
+
+    // Ensure recipes that fail have an exception message.
+    $process = $this->applyRecipe('core/tests/fixtures/recipes/invalid_config', 1);
+    $this->assertStringContainsString("There were validation errors in core.date_format.invalid", $process->getErrorOutput());
+    $this->assertCheckpointsExist([
+      "Backup before the 'Install node with config' recipe.",
+      "Backup before the 'Install two modules' recipe.",
+      // Although the recipe command tried to create a checkpoint, it did not
+      // actually happen, because of https://drupal.org/i/3408523.
+    ]);
+
+    // Create a checkpoint so we can test what happens when a recipe does not
+    // create a checkpoint before applying.
+    \Drupal::service('config.storage.checkpoint')->checkpoint('Test log message');
+    $process = $this->applyRecipe('core/tests/fixtures/recipes/no_extensions');
+    $this->assertSame(0, $process->getExitCode());
+    $this->assertStringContainsString("Applied No extensions recipe.", $process->getErrorOutput());
+    $this->assertCheckpointsExist([
+      "Backup before the 'Install node with config' recipe.",
+      "Backup before the 'Install two modules' recipe.",
+      "Test log message",
+    ]);
+    $this->assertStringContainsString('[notice] A backup checkpoint was not created because nothing has changed since the "Test log message" checkpoint was created.', $process->getOutput());
+  }
+
+  /**
+   * Tests that errors during config rollback won't steamroll validation errors.
+   */
+  public function testExceptionOnRollback(): void {
+    $process = $this->applyRecipe('core/tests/fixtures/recipes/config_rollback_exception', 1);
+
+    // The error from the config importer should be visible.
+    $output = $process->getOutput();
+    $this->assertStringContainsString('There were errors validating the config synchronization.', $output);
+    $this->assertStringContainsString('Provides a filter plugin that is in use', $output);
+    // And the exception that actually *caused* the error should be visible too.
+    $this->assertStringContainsString('There were validation errors in system.image:', $process->getErrorOutput());
+  }
+
+  /**
+   * Asserts that the current set of checkpoints matches the given labels.
+   *
+   * @param string[] $expected_labels
+   *   The labels of every checkpoint that is expected to exist currently, in
+   *   the expected order.
+   */
+  private function assertCheckpointsExist(array $expected_labels): void {
+    $checkpoints = \Drupal::service('config.checkpoints');
+    $labels = array_map(fn (Checkpoint $c) => $c->label, iterator_to_array($checkpoints));
+    $this->assertSame($expected_labels, array_values($labels));
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..601dced29634dc77c3e9e5d1fbf87d913655759d
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeTestTrait.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Tests\BrowserTestBase;
+use Symfony\Component\Process\PhpExecutableFinder;
+use Symfony\Component\Process\Process;
+
+/**
+ * Contains helper methods for interacting with recipes in functional tests.
+ */
+trait RecipeTestTrait {
+
+  /**
+   * Creates a recipe in a temporary directory.
+   *
+   * @param string|array<mixed> $data
+   *   The contents of recipe.yml. If passed as an array, will be encoded to
+   *   YAML.
+   * @param string|null $machine_name
+   *   The machine name for the recipe. Will be used as the directory name.
+   *
+   * @return \Drupal\Core\Recipe\Recipe
+   *   The recipe object.
+   */
+  protected function createRecipe(string|array $data, ?string $machine_name = NULL): Recipe {
+    if (is_array($data)) {
+      $data = Yaml::encode($data);
+    }
+    $recipes_dir = $this->siteDirectory . '/recipes';
+    if ($machine_name === NULL) {
+      $dir = uniqid($recipes_dir . '/');
+    }
+    else {
+      $dir = $recipes_dir . '/' . $machine_name;
+    }
+    mkdir($dir, recursive: TRUE);
+    file_put_contents($dir . '/recipe.yml', $data);
+
+    return Recipe::createFromDirectory($dir);
+  }
+
+  /**
+   * Applies a recipe to the site.
+   *
+   * @param string $path
+   *   The path of the recipe to apply. Must be a directory.
+   * @param int $expected_exit_code
+   *   The expected exit code of the `drupal recipe` process. Defaults to 0,
+   *   which indicates that no error occurred.
+   *
+   * @return \Symfony\Component\Process\Process
+   *   The `drupal recipe` command process, after having run.
+   */
+  protected function applyRecipe(string $path, int $expected_exit_code = 0): Process {
+    assert($this instanceof BrowserTestBase);
+    $this->assertDirectoryExists($path);
+
+    $arguments = [
+      (new PhpExecutableFinder())->find(),
+      'core/scripts/drupal',
+      'recipe',
+      $path,
+    ];
+    $process = (new Process($arguments))
+      ->setWorkingDirectory($this->getDrupalRoot())
+      ->setEnv([
+        'DRUPAL_DEV_SITE_PATH' => $this->siteDirectory,
+        // Ensure that the command boots Drupal into a state where it knows it's
+        // a test site.
+        // @see drupal_valid_test_ua()
+        'HTTP_USER_AGENT' => drupal_generate_test_ua($this->databasePrefix),
+      ])
+      ->setTimeout(500);
+
+    $process->run();
+    $this->assertSame($expected_exit_code, $process->getExitCode(), $process->getErrorOutput());
+    // Applying a recipe:
+    // - creates new checkpoints, hence the "state" service in the test runner
+    //   is outdated
+    // - may install modules, which would cause the entire container in the test
+    //   runner to be outdated.
+    // Hence the entire environment must be rebuilt for assertions to target the
+    // actual post-recipe-application result.
+    // @see \Drupal\Core\Config\Checkpoint\LinearHistory::__construct()
+    $this->rebuildAll();
+    return $process;
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e7509aa8528120c487fffb6df94df3c7f7b1aeaa
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeInstallTest.php
@@ -0,0 +1,129 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\contact\Entity\ContactForm;
+use Drupal\FunctionalTests\Installer\InstallerTestBase;
+use Drupal\shortcut\Entity\Shortcut;
+use Drupal\Tests\standard\Traits\StandardTestTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Yaml\Yaml as SymfonyYaml;
+
+/**
+ * Tests installing the Standard recipe via the installer.
+ *
+ * @group #slow
+ * @group Recipe
+ */
+class StandardRecipeInstallTest extends InstallerTestBase {
+  use StandardTestTrait {
+    testStandard as doTestStandard;
+  }
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $profile = '';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // Skip permissions hardening so we can write a services file later.
+    $this->settings['settings']['skip_permissions_hardening'] = (object) [
+      'value' => TRUE,
+      'required' => TRUE,
+    ];
+
+    parent::setUp();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function visitInstaller(): void {
+    // Use a URL to install from a recipe.
+    $this->drupalGet($GLOBALS['base_url'] . '/core/install.php' . '?profile=&recipe=core/recipes/standard');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testStandard(): void {
+    if (!isset($this->rootUser->passRaw) && isset($this->rootUser->pass_raw)) {
+      $this->rootUser->passRaw = $this->rootUser->pass_raw;
+    }
+    // These recipes provide functionality that is only optionally part of the
+    // Standard profile, so we need to explicitly apply them.
+    $this->applyRecipe('core/recipes/editorial_workflow');
+    $this->applyRecipe('core/recipes/audio_media_type');
+    $this->applyRecipe('core/recipes/document_media_type');
+    $this->applyRecipe('core/recipes/image_media_type');
+    $this->applyRecipe('core/recipes/local_video_media_type');
+    $this->applyRecipe('core/recipes/remote_video_media_type');
+
+    // Add a Home link to the main menu as Standard expects "Main navigation"
+    // block on the page.
+    $this->drupalGet('admin/structure/menu/manage/main/add');
+    $this->submitForm([
+      'title[0][value]' => 'Home',
+      'link[0][uri]' => '<front>',
+    ], 'Save');
+
+    // Standard expects to set the contact form's recipient email to the
+    // system's email address, but our feedback_contact_form recipe hard-codes
+    // it to another value.
+    // @todo This can be removed after https://drupal.org/i/3303126, which
+    //   should make it possible for a recipe to reuse an already-set config
+    //   value.
+    ContactForm::load('feedback')?->setRecipients(['simpletest@example.com'])
+      ->save();
+
+    // Standard ships two shortcuts; ensure they exist.
+    $this->assertCount(2, Shortcut::loadMultiple());
+
+    // The installer logs you in.
+    $this->drupalLogout();
+
+    $this->doTestStandard();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpProfile(): void {
+    // Noop. This form is skipped due the parameters set on the URL.
+  }
+
+  protected function installDefaultThemeFromClassProperty(ContainerInterface $container): void {
+    // In this context a default theme makes no sense.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function installResponsiveImage(): void {
+    // Overrides StandardTest::installResponsiveImage() in order to use the
+    // recipe.
+    $this->applyRecipe('core/recipes/standard_responsive_images');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpSite(): void {
+    $services_file = DRUPAL_ROOT . '/' . $this->siteDirectory . '/services.yml';
+    // $content = file_get_contents($services_file);
+
+    // Disable the super user access.
+    $yaml = new SymfonyYaml();
+    $services = [];
+    $services['parameters']['security.enable_super_user'] = FALSE;
+    file_put_contents($services_file, $yaml->dump($services));
+    parent::setUpSite();
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2e66805c898cdc5cafa71e8f0412bdf0953604cb
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php
@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Core\Recipe;
+
+use Drupal\contact\Entity\ContactForm;
+use Drupal\shortcut\Entity\Shortcut;
+use Drupal\Tests\standard\Functional\StandardTest;
+use Drupal\user\RoleInterface;
+
+/**
+ * Tests Standard recipe installation expectations.
+ *
+ * @group #slow
+ * @group Recipe
+ */
+class StandardRecipeTest extends StandardTest {
+
+  use RecipeTestTrait;
+
+  /**
+   * Tests Standard installation recipe.
+   */
+  public function testStandard(): void {
+    // Install some modules that Standard has optional integrations with.
+    \Drupal::service('module_installer')->install(['media_library', 'content_moderation']);
+
+    // Export all the configuration so we can compare later.
+    $this->copyConfig(\Drupal::service('config.storage'), \Drupal::service('config.storage.sync'));
+
+    // Set theme to stark and uninstall the other themes.
+    $theme_installer = \Drupal::service('theme_installer');
+    $theme_installer->install(['stark']);
+    $this->config('system.theme')->set('admin', '')->set('default', 'stark')->save();
+    $theme_installer->uninstall(['claro', 'olivero']);
+
+    // Determine which modules to uninstall.
+    $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), ['user', 'system', 'path_alias', \Drupal::database()->getProvider()]);
+    foreach (['shortcut', 'field_config', 'filter_format', 'field_storage_config'] as $entity_type) {
+      $storage = \Drupal::entityTypeManager()->getStorage($entity_type);
+      $storage->delete($storage->loadMultiple());
+    }
+
+    // Uninstall all the modules including the Standard profile.
+    \Drupal::service('module_installer')->uninstall($uninstall);
+
+    // Clean up entity displays before recipe import.
+    foreach (['entity_form_display', 'entity_view_display'] as $entity_type) {
+      $storage = \Drupal::entityTypeManager()->getStorage($entity_type);
+      $storage->delete($storage->loadMultiple());
+    }
+
+    // Clean up roles before recipe import.
+    $storage = \Drupal::entityTypeManager()->getStorage('user_role');
+    $roles = $storage->loadMultiple();
+    // Do not delete the administrator role. There would be no user with the
+    // permissions to create content.
+    unset($roles[RoleInterface::ANONYMOUS_ID], $roles[RoleInterface::AUTHENTICATED_ID], $roles['administrator']);
+    $storage->delete($roles);
+
+    $this->applyRecipe('core/recipes/standard');
+    // These recipes provide functionality that is only optionally part of the
+    // Standard profile, so we need to explicitly apply them.
+    $this->applyRecipe('core/recipes/editorial_workflow');
+    $this->applyRecipe('core/recipes/audio_media_type');
+    $this->applyRecipe('core/recipes/document_media_type');
+    $this->applyRecipe('core/recipes/image_media_type');
+    $this->applyRecipe('core/recipes/local_video_media_type');
+    $this->applyRecipe('core/recipes/remote_video_media_type');
+
+    // Remove the theme we had to install.
+    \Drupal::service('theme_installer')->uninstall(['stark']);
+
+    // Add a Home link to the main menu as Standard expects "Main navigation"
+    // block on the page.
+    $this->drupalLogin($this->rootUser);
+    $this->drupalGet('admin/structure/menu/manage/main/add');
+    $this->submitForm([
+      'title[0][value]' => 'Home',
+      'link[0][uri]' => '<front>',
+    ], 'Save');
+
+    // Standard expects to set the contact form's recipient email to the
+    // system's email address, but our feedback_contact_form recipe hard-codes
+    // it to another value.
+    // @todo This can be removed after https://drupal.org/i/3303126, which
+    //   should make it possible for a recipe to reuse an already-set config
+    //   value.
+    ContactForm::load('feedback')?->setRecipients(['simpletest@example.com'])
+      ->save();
+
+    // Update sync directory config to have the same UUIDs so we can compare.
+    /** @var \Drupal\Core\Config\StorageInterface $sync */
+    $sync = \Drupal::service('config.storage.sync');
+    /** @var \Drupal\Core\Config\StorageInterface $active */
+    $active = \Drupal::service('config.storage');
+    // @todo https://www.drupal.org/i/3439749 Determine if the the _core unset
+    //   is correct.
+    foreach ($active->listAll() as $name) {
+      /** @var mixed[] $active_data */
+      $active_data = $active->read($name);
+      if ($sync->exists($name)) {
+        /** @var mixed[] $sync_data */
+        $sync_data = $sync->read($name);
+        if (isset($sync_data['uuid'])) {
+          $sync_data['uuid'] = $active_data['uuid'];
+        }
+        if (isset($sync_data['_core'])) {
+          unset($sync_data['_core']);
+        }
+        /** @var array $sync_data */
+        $sync->write($name, $sync_data);
+      }
+      if (isset($active_data['_core'])) {
+        unset($active_data['_core']);
+        $active->write($name, $active_data);
+      }
+      // @todo Remove this once https://drupal.org/i/3427564 lands.
+      if ($name === 'node.settings') {
+        unset($active_data['langcode']);
+        $active->write($name, $active_data);
+      }
+    }
+
+    // Ensure we have truly rebuilt the standard profile using recipes.
+    // Uncomment the code below to see the differences in a single file.
+    // $this->assertSame($sync->read('node.settings'), $active->read('node.settings'));
+    $comparer = $this->configImporter()->getStorageComparer();
+    $expected_list = $comparer->getEmptyChangelist();
+    // We expect core.extension to be different because standard is no longer
+    // installed.
+    $expected_list['update'] = ['core.extension'];
+    $this->assertSame($expected_list, $comparer->getChangelist());
+
+    // Standard ships two shortcuts; ensure they exist.
+    $this->assertCount(2, Shortcut::loadMultiple());
+
+    parent::testStandard();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function installResponsiveImage(): void {
+    // Overrides StandardTest::installResponsiveImage() in order to use the
+    // recipe.
+    $this->applyRecipe('core/recipes/standard_responsive_images');
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php b/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a4191b29cc115d9ea7d35bfcadcc3ddc75755cf5
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php
@@ -0,0 +1,263 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\DefaultContent;
+
+use ColinODell\PsrTestLogger\TestLogger;
+use Drupal\block_content\BlockContentInterface;
+use Drupal\block_content\Entity\BlockContentType;
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\DefaultContent\Existing;
+use Drupal\Core\DefaultContent\Finder;
+use Drupal\Core\DefaultContent\Importer;
+use Drupal\Core\DefaultContent\ImportException;
+use Drupal\Core\DefaultContent\InvalidEntityException;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\File\FileExists;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\file\FileInterface;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\language\Entity\ContentLanguageSettings;
+use Drupal\media\MediaInterface;
+use Drupal\menu_link_content\MenuLinkContentInterface;
+use Drupal\node\NodeInterface;
+use Drupal\taxonomy\TermInterface;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait;
+use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
+use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
+use Psr\Log\LogLevel;
+
+/**
+ * @covers \Drupal\Core\DefaultContent\Importer
+ * @group DefaultContent
+ * @group Recipe
+ */
+class ContentImportTest extends BrowserTestBase {
+
+  use EntityReferenceFieldCreationTrait;
+  use MediaTypeCreationTrait;
+  use RecipeTestTrait;
+  use TaxonomyTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'block_content',
+    'content_translation',
+    'entity_test',
+    'media',
+    'menu_link_content',
+    'node',
+    'path',
+    'path_alias',
+    'system',
+    'taxonomy',
+    'user',
+  ];
+
+  private readonly string $contentDir;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->setUpCurrentUser(admin: TRUE);
+
+    BlockContentType::create(['id' => 'basic', 'label' => 'Basic'])->save();
+    block_content_add_body_field('basic');
+
+    $this->createVocabulary(['vid' => 'tags']);
+    $this->createMediaType('image', ['id' => 'image']);
+    $this->drupalCreateContentType(['type' => 'page']);
+    $this->drupalCreateContentType(['type' => 'article']);
+    $this->createEntityReferenceField('node', 'article', 'field_tags', 'Tags', 'taxonomy_term');
+
+    // Create a field with custom serialization, so we can ensure that the
+    // importer handles that properly.
+    $field_storage = FieldStorageConfig::create([
+      'entity_type' => 'taxonomy_term',
+      'field_name' => 'field_serialized_stuff',
+      'type' => 'serialized_property_item_test',
+    ]);
+    $field_storage->save();
+    FieldConfig::create([
+      'field_storage' => $field_storage,
+      'bundle' => 'tags',
+    ])->save();
+
+    ConfigurableLanguage::createFromLangcode('fr')->save();
+    ContentLanguageSettings::create([
+      'target_entity_type_id' => 'node',
+      'target_bundle' => 'article',
+    ])
+      ->setThirdPartySetting('content_translation', 'enabled', TRUE)
+      ->save();
+
+    $this->contentDir = $this->getDrupalRoot() . '/core/tests/fixtures/default_content';
+    \Drupal::service('file_system')->copy($this->contentDir . '/file/druplicon_copy.png', $this->publicFilesDirectory . '/druplicon_copy.png', FileExists::Error);
+  }
+
+  /**
+   * @return array<array<mixed>>
+   */
+  public static function providerImportEntityThatAlreadyExists(): array {
+    return [
+      [Existing::Error],
+      [Existing::Skip],
+    ];
+  }
+
+  /**
+   * @dataProvider providerImportEntityThatAlreadyExists
+   */
+  public function testImportEntityThatAlreadyExists(Existing $existing): void {
+    $this->drupalCreateUser(values: ['uuid' => '94503467-be7f-406c-9795-fc25baa22203']);
+
+    if ($existing === Existing::Error) {
+      $this->expectException(ImportException::class);
+      $this->expectExceptionMessage('user 94503467-be7f-406c-9795-fc25baa22203 already exists.');
+    }
+
+    $this->container->get(Importer::class)
+      ->importContent(new Finder($this->contentDir), $existing);
+  }
+
+  /**
+   * Tests importing content directly, via the API.
+   */
+  public function testDirectContentImport(): void {
+    $logger = new TestLogger();
+
+    /** @var \Drupal\Core\DefaultContent\Importer $importer */
+    $importer = $this->container->get(Importer::class);
+    $importer->setLogger($logger);
+    $importer->importContent(new Finder($this->contentDir));
+
+    $this->assertContentWasImported();
+    // We should see a warning about importing a file entity associated with a
+    // file that doesn't exist.
+    $predicate = function (array $record): bool {
+      return (
+        $record['message'] === 'File entity %name was imported, but the associated file (@path) was not found.' &&
+        $record['context']['%name'] === 'dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png' &&
+        $record['context']['@path'] === $this->contentDir . '/file/dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png'
+      );
+    };
+    $this->assertTrue($logger->hasRecordThatPasses($predicate, LogLevel::WARNING));
+  }
+
+  /**
+   * Tests that the importer validates entities before saving them.
+   */
+  public function testEntityValidationIsTriggered(): void {
+    $dir = uniqid('public://');
+    mkdir($dir);
+
+    /** @var string $data */
+    $data = file_get_contents($this->contentDir . '/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml');
+    $data = Yaml::decode($data);
+    /** @var array{default: array{sticky: array<int, array{value: mixed}>}} $data */
+    $data['default']['sticky'][0]['value'] = 'not a boolean!';
+    file_put_contents($dir . '/invalid.yml', Yaml::encode($data));
+
+    $this->expectException(InvalidEntityException::class);
+    $this->expectExceptionMessage("$dir/invalid.yml: sticky.0.value=This value should be of the correct primitive type.");
+    $this->container->get(Importer::class)->importContent(new Finder($dir));
+  }
+
+  /**
+   * Asserts that the default content was imported as expected.
+   */
+  private function assertContentWasImported(): void {
+    /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
+    $entity_repository = $this->container->get(EntityRepositoryInterface::class);
+
+    $node = $entity_repository->loadEntityByUuid('node', 'e1714f23-70c0-4493-8e92-af1901771921');
+    $this->assertInstanceOf(NodeInterface::class, $node);
+    $this->assertSame('Crikey it works!', $node->body->value);
+    $this->assertSame('article', $node->bundle());
+    $this->assertSame('Test Article', $node->label());
+    $tag = $node->field_tags->entity;
+    $this->assertInstanceOf(TermInterface::class, $tag);
+    $this->assertSame('Default Content', $tag->label());
+    $this->assertSame('tags', $tag->bundle());
+    $this->assertSame('550f86ad-aa11-4047-953f-636d42889f85', $tag->uuid());
+    // The tag carries a field with serialized data, so ensure it came through
+    // properly.
+    $this->assertSame('a:2:{i:0;s:2:"Hi";i:1;s:6:"there!";}', $tag->field_serialized_stuff->value);
+    $this->assertSame('94503467-be7f-406c-9795-fc25baa22203', $node->getOwner()->uuid());
+    // The node's URL should use the path alias shipped with the recipe.
+    $node_url = $node->toUrl()->toString();
+    $this->assertSame(Url::fromUserInput('/test-article')->toString(), $node_url);
+
+    $media = $entity_repository->loadEntityByUuid('media', '344b943c-b231-4d73-9669-0b0a2be12aa5');
+    $this->assertInstanceOf(MediaInterface::class, $media);
+    $this->assertSame('image', $media->bundle());
+    $this->assertSame('druplicon.png', $media->label());
+    $file = $media->field_media_image->entity;
+    $this->assertInstanceOf(FileInterface::class, $file);
+    $this->assertSame('druplicon.png', $file->getFilename());
+    $this->assertSame('d8404562-efcc-40e3-869e-40132d53fe0b', $file->uuid());
+
+    // Another file entity referencing an existing file but already in use by
+    // another entity, should be imported.
+    $same_file_different_entity = $entity_repository->loadEntityByUuid('file', '23a7f61f-1db3-407d-a6dd-eb4731995c9f');
+    $this->assertInstanceOf(FileInterface::class, $same_file_different_entity);
+    $this->assertSame('druplicon-duplicate.png', $same_file_different_entity->getFilename());
+    $this->assertStringEndsWith('/druplicon_0.png', (string) $same_file_different_entity->getFileUri());
+
+    // Another file entity that references a file with the same name as, but
+    // different contents than, an existing file, should be imported and the
+    // file should be renamed.
+    $different_file = $entity_repository->loadEntityByUuid('file', 'a6b79928-838f-44bd-a8f0-44c2fff9e4cc');
+    $this->assertInstanceOf(FileInterface::class, $different_file);
+    $this->assertSame('druplicon-different.png', $different_file->getFilename());
+    $this->assertStringEndsWith('/druplicon_1.png', (string) $different_file->getFileUri());
+
+    // Another file entity referencing an existing file but one that is not in
+    // use by another entity, should be imported but use the existing file.
+    $different_file = $entity_repository->loadEntityByUuid('file', '7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d');
+    $this->assertInstanceOf(FileInterface::class, $different_file);
+    $this->assertSame('druplicon_copy.png', $different_file->getFilename());
+    $this->assertStringEndsWith('/druplicon_copy.png', (string) $different_file->getFileUri());
+
+    // Our node should have a menu link, and it should use the path alias we
+    // included with the recipe.
+    $menu_link = $entity_repository->loadEntityByUuid('menu_link_content', '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b');
+    $this->assertInstanceOf(MenuLinkContentInterface::class, $menu_link);
+    $this->assertSame($menu_link->getUrlObject()->toString(), $node_url);
+    $this->assertSame('main', $menu_link->getMenuName());
+
+    $block_content = $entity_repository->loadEntityByUuid('block_content', 'd9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf');
+    $this->assertInstanceOf(BlockContentInterface::class, $block_content);
+    $this->assertSame('basic', $block_content->bundle());
+    $this->assertSame('Useful Info', $block_content->label());
+    $this->assertSame("I'd love to put some useful info here.", $block_content->body->value);
+
+    // A node with a non-existent owner should be reassigned to the current
+    // user.
+    $node = $entity_repository->loadEntityByUuid('node', '7f1dd75a-0be2-4d3b-be5d-9d1a868b9267');
+    $this->assertInstanceOf(NodeInterface::class, $node);
+    $this->assertSame(\Drupal::currentUser()->id(), $node->getOwner()->id());
+
+    // Ensure a node with a translation is imported properly.
+    $node = $entity_repository->loadEntityByUuid('node', '2d3581c3-92c7-4600-8991-a0d4b3741198');
+    $this->assertInstanceOf(NodeInterface::class, $node);
+    $translation = $node->getTranslation('fr');
+    $this->assertSame('Perdu en traduction', $translation->label());
+    $this->assertSame("Içi c'est la version français.", $translation->body->value);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ec34c1036b738d87a44df79d85d769b316e2fa44
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php
@@ -0,0 +1,324 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Config\Action;
+
+// cspell:ignore inflector
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Component\Uuid\Uuid;
+use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName;
+use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Config\Action\DuplicateConfigActionIdException;
+use Drupal\Core\Config\Action\EntityMethodException;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests the config action system.
+ *
+ * @group config
+ */
+class ConfigActionTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['config_test'];
+
+  /**
+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate
+   */
+  public function testEntityCreate(): void {
+    $this->assertCount(0, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There are no config_test entities');
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']);
+    /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */
+    $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
+    $this->assertCount(1, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There is 1 config_test entity');
+    $this->assertSame('Action test', $config_test_entities['action_test']->label());
+    $this->assertTrue(Uuid::isValid((string) $config_test_entities['action_test']->uuid()), 'Config entity assigned a valid UUID');
+
+    // Calling ensure exists action again will not error.
+    $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']);
+
+    try {
+      $manager->applyAction('entity_create:create', 'config_test.dynamic.action_test', ['label' => 'Action test']);
+      $this->fail('Expected exception not thrown');
+    }
+    catch (ConfigActionException $e) {
+      $this->assertSame('Entity config_test.dynamic.action_test exists', $e->getMessage());
+    }
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
+   */
+  public function testEntityMethod(): void {
+    $this->installConfig('config_test');
+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('Default', $config_test_entity->getProtectedProperty());
+
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    // Call a method action.
+    $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('Test value', $config_test_entity->getProtectedProperty());
+
+    $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value 2');
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('Test value 2', $config_test_entity->getProtectedProperty());
+
+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', ['Test value ', '3']);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('Test value 3', $config_test_entity->getProtectedProperty());
+
+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value ', '4']);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('Test value 4', $config_test_entity->getProtectedProperty());
+
+    // Test calling an action that has 2 arguments but one is optional with an
+    // array value.
+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value 5']);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('Test value 5', $config_test_entity->getProtectedProperty());
+
+    // Test calling an action that has 2 arguments but one is optional with a
+    // non array value.
+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', 'Test value 6');
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('Test value 6', $config_test_entity->getProtectedProperty());
+
+    // Test calling an action that expects no arguments.
+    $manager->applyAction('entity_method:config_test.dynamic:defaultProtectedProperty', 'config_test.dynamic.dotted.default', []);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('Set by method', $config_test_entity->getProtectedProperty());
+
+    $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'foo');
+    $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'bar');
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame(['foo', 'bar'], $config_test_entity->getArrayProperty());
+
+    $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame(['foo', 'bar', ['a', 'b', 'c']], $config_test_entity->getArrayProperty());
+
+    $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame(['a', 'b', 'c'], $config_test_entity->getArrayProperty());
+
+    $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', [['a', 'b', 'c'], ['a']]);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame([['a', 'b', 'c'], ['a']], $config_test_entity->getArrayProperty());
+
+    $config_test_entity->delete();
+    try {
+      $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
+      $this->fail('Expected exception not thrown');
+    }
+    catch (ConfigActionException $e) {
+      $this->assertSame('Entity config_test.dynamic.dotted.default does not exist', $e->getMessage());
+    }
+
+    // Test custom and default admin labels.
+    $this->assertSame('Test configuration append', (string) $manager->getDefinition('entity_method:config_test.dynamic:append')['admin_label']);
+    $this->assertSame('Set default name', (string) $manager->getDefinition('entity_method:config_test.dynamic:defaultProtectedProperty')['admin_label']);
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
+   */
+  public function testPluralizedEntityMethod(): void {
+    $this->installConfig('config_test');
+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    // Call a pluralized method action.
+    $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', ['a', 'b', 'c', 'd']);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame(['a', 'b', 'c', 'd'], $config_test_entity->getArrayProperty());
+
+    $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', [['foo'], 'bar']);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame(['a', 'b', 'c', 'd', ['foo'], 'bar'], $config_test_entity->getArrayProperty());
+
+    $config_test_entity->setProtectedProperty('')->save();
+    $manager->applyAction('entity_method:config_test.dynamic:appends', 'config_test.dynamic.dotted.default', ['1', '2', '3']);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('123', $config_test_entity->getProtectedProperty());
+
+    // Test that the inflector converts to a good plural form.
+    $config_test_entity->setProtectedProperty('')->save();
+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperties', 'config_test.dynamic.dotted.default', [['1', '2'], ['3', '4']]);
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('dotted.default');
+    $this->assertSame('34', $config_test_entity->getProtectedProperty());
+
+    $this->assertTrue($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedProperty'), 'The setProtectedProperty action exists');
+    // cspell:ignore Propertys
+    $this->assertFalse($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedPropertys'), 'There is no automatically pluralized version of the setProtectedProperty action');
+
+    // Admin label for pluralized form.
+    $this->assertSame('Test configuration append (multiple calls)', (string) $manager->getDefinition('entity_method:config_test.dynamic:appends')['admin_label']);
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
+   */
+  public function testPluralizedEntityMethodException(): void {
+    $this->installConfig('config_test');
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    $this->expectException(EntityMethodException::class);
+    $this->expectExceptionMessage('The pluralized entity method config action \'entity_method:config_test.dynamic:addToArrayMultipleTimes\' requires an array value in order to call Drupal\config_test\Entity\ConfigTest::addToArray() multiple times');
+    $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', 'Test value');
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver
+   */
+  public function testDuplicatePluralizedMethodNameException(): void {
+    \Drupal::state()->set('config_test.class_override', DuplicatePluralizedMethodName::class);
+    \Drupal::entityTypeManager()->clearCachedDefinitions();
+    $this->installConfig('config_test');
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    $this->expectException(EntityMethodException::class);
+    $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName::testMethod(). The existing action is for the ::testMethod() method');
+    $manager->getDefinitions();
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver
+   */
+  public function testDuplicatePluralizedOtherMethodNameException(): void {
+    \Drupal::state()->set('config_test.class_override', DuplicatePluralizedOtherMethodName::class);
+    \Drupal::entityTypeManager()->clearCachedDefinitions();
+    $this->installConfig('config_test');
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    $this->expectException(EntityMethodException::class);
+    $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod2\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName::testMethod2(). The existing action is for the ::testMethod() method');
+    $manager->getDefinitions();
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
+   */
+  public function testEntityMethodException(): void {
+    $this->installConfig('config_test');
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    $this->expectException(EntityMethodException::class);
+    $this->expectExceptionMessage('Entity method config action \'entity_method:config_test.dynamic:concatProtectedProperty\' requires an array value. The number of parameters or required parameters for Drupal\config_test\Entity\ConfigTest::concatProtectedProperty() is not 1');
+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate
+   */
+  public function testSimpleConfigUpdate(): void {
+    $this->installConfig('config_test');
+    $this->assertSame('bar', $this->config('config_test.system')->get('foo'));
+
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    // Call the simple config update action.
+    $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']);
+    $this->assertSame('Yay!', $this->config('config_test.system')->get('foo'));
+
+    try {
+      $manager->applyAction('simple_config_update', 'config_test.system', 'Test');
+      $this->fail('Expected exception not thrown');
+    }
+    catch (ConfigActionException $e) {
+      $this->assertSame('Config config_test.system can not be updated because $value is not an array', $e->getMessage());
+    }
+
+    $this->config('config_test.system')->delete();
+    try {
+      $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']);
+      $this->fail('Expected exception not thrown');
+    }
+    catch (ConfigActionException $e) {
+      $this->assertSame('Config config_test.system does not exist so can not be updated', $e->getMessage());
+    }
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
+   */
+  public function testShorthandActionIds(): void {
+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+    $this->assertCount(0, $storage->loadMultiple(), 'There are no config_test entities');
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']);
+    /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */
+    $config_test_entities = $storage->loadMultiple();
+    $this->assertCount(1, $config_test_entities, 'There is 1 config_test entity');
+    $this->assertSame('Action test', $config_test_entities['action_test']->label());
+
+    $this->assertSame('', $config_test_entities['action_test']->getProtectedProperty());
+
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    // Call a method action.
+    $manager->applyAction('setProtectedProperty', 'config_test.dynamic.action_test', 'Test value');
+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
+    $config_test_entity = $storage->load('action_test');
+    $this->assertSame('Test value', $config_test_entity->getProtectedProperty());
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
+   */
+  public function testDuplicateShorthandActionIds(): void {
+    $this->enableModules(['config_action_duplicate_test']);
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    $this->expectException(DuplicateConfigActionIdException::class);
+    $this->expectExceptionMessage("The plugins 'entity_method:config_test.dynamic:setProtectedProperty' and 'config_action_duplicate_test:config_test.dynamic:setProtectedProperty' both resolve to the same shorthand action ID for the 'config_test' entity type");
+    $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']);
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
+   */
+  public function testParentAttributes(): void {
+    $definitions = $this->container->get('plugin.manager.config_action')->getDefinitions();
+    // The \Drupal\config_test\Entity\ConfigQueryTest::concatProtectedProperty()
+    // does not have an attribute but the parent does so this is discovered.
+    $this->assertArrayHasKey('entity_method:config_test.query:concatProtectedProperty', $definitions);
+  }
+
+  /**
+   * @see \Drupal\Core\Config\Action\ConfigActionManager
+   */
+  public function testMissingAction(): void {
+    $this->expectException(PluginNotFoundException::class);
+    $this->expectExceptionMessageMatches('/^The "does_not_exist" plugin does not exist/');
+    $this->container->get('plugin.manager.config_action')->applyAction('does_not_exist', 'config_test.system', ['foo' => 'Yay!']);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php b/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9032d234bce6be894ca04d4a33c9420150fd77cd
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Config/Storage/Checkpoint/CheckpointStorageTest.php
@@ -0,0 +1,310 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Config\Storage\Checkpoint;
+
+use Drupal\Core\Config\Checkpoint\CheckpointStorageInterface;
+use Drupal\Core\Config\ConfigImporter;
+use Drupal\Core\Config\StorageComparer;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests CheckpointStorage operations.
+ *
+ * @group config
+ */
+class CheckpointStorageTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['system', 'config_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installConfig(['system', 'config_test']);
+  }
+
+  public function testConfigSaveAndRead(): void {
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+
+    $this->config('system.site')->set('name', 'Test1')->save();
+    $check1 = $checkpoint_storage->checkpoint('A');
+    $this->config('system.site')->set('name', 'Test2')->save();
+    $check2 = $checkpoint_storage->checkpoint('B');
+    $this->config('system.site')->set('name', 'Test3')->save();
+
+    $this->assertSame('Test3', $this->config('system.site')->get('name'));
+    $this->assertSame('Test1', $checkpoint_storage->read('system.site')['name']);
+
+    // The config listings should be exactly the same.
+    $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll());
+
+    $checkpoint_storage->setCheckpointToReadFrom($check2);
+    $this->assertSame('Test2', $checkpoint_storage->read('system.site')['name']);
+    $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll());
+
+    $checkpoint_storage->setCheckpointToReadFrom($check1);
+    $this->assertSame('Test1', $checkpoint_storage->read('system.site')['name']);
+    $this->assertSame($checkpoint_storage->listAll(), $this->container->get('config.storage')->listAll());
+  }
+
+  public function testConfigDelete(): void {
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+
+    $check1 = $checkpoint_storage->checkpoint('A');
+    $this->config('config_test.system')->delete();
+
+    $this->assertFalse($this->container->get('config.storage')->exists('config_test.system'));
+    $this->assertTrue($checkpoint_storage->exists('config_test.system'));
+    $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']);
+
+    $this->assertContains('config_test.system', $checkpoint_storage->listAll());
+    $this->assertContains('config_test.system', $checkpoint_storage->listAll('config_test.'));
+    $this->assertNotContains('config_test.system', $checkpoint_storage->listAll('system.'));
+    // Should not be part of the active storage anymore.
+    $this->assertNotContains('config_test.system', $this->container->get('config.storage')->listAll());
+
+    $check2 = $checkpoint_storage->checkpoint('B');
+
+    $this->config('config_test.system')->set('foo', 'foobar')->save();
+    $this->assertTrue($this->container->get('config.storage')->exists('config_test.system'));
+    $this->assertTrue($checkpoint_storage->exists('config_test.system'));
+    $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']);
+
+    $checkpoint_storage->setCheckpointToReadFrom($check2);
+    $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+    $this->assertFalse($checkpoint_storage->read('config_test.system'));
+    $this->assertNotContains('config_test.system', $checkpoint_storage->listAll());
+
+    $checkpoint_storage->setCheckpointToReadFrom($check1);
+    $this->assertTrue($checkpoint_storage->exists('config_test.system'));
+    $this->assertSame('bar', $checkpoint_storage->read('config_test.system')['foo']);
+    $this->assertContains('config_test.system', $checkpoint_storage->listAll());
+  }
+
+  public function testConfigCreate(): void {
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+
+    $this->config('config_test.system')->delete();
+    $check1 = $checkpoint_storage->checkpoint('A');
+    $this->config('config_test.system')->set('foo', 'foobar')->save();
+
+    $this->assertTrue($this->container->get('config.storage')->exists('config_test.system'));
+    $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+    $this->assertFalse($checkpoint_storage->read('config_test.system'));
+
+    $this->assertNotContains('config_test.system', $checkpoint_storage->listAll());
+    $this->assertNotContains('config_test.system', $checkpoint_storage->listAll('config_test.'));
+    $this->assertContains('system.site', $checkpoint_storage->listAll('system.'));
+    $this->assertContains('config_test.system', $this->container->get('config.storage')->listAll());
+
+    $check2 = $checkpoint_storage->checkpoint('B');
+    $this->config('config_test.system')->delete();
+
+    $this->assertFalse($this->container->get('config.storage')->exists('config_test.system'));
+    $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+    $this->assertFalse($checkpoint_storage->read('config_test.system'));
+
+    $this->config('config_test.system')->set('foo', 'foobar')->save();
+    $this->assertTrue($this->container->get('config.storage')->exists('config_test.system'));
+    $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+    $this->assertFalse($checkpoint_storage->read('config_test.system'));
+
+    $checkpoint_storage->setCheckpointToReadFrom($check2);
+    $this->assertTrue($checkpoint_storage->exists('config_test.system'));
+    $this->assertSame('foobar', $checkpoint_storage->read('config_test.system')['foo']);
+    $this->assertContains('config_test.system', $checkpoint_storage->listAll());
+
+    $checkpoint_storage->setCheckpointToReadFrom($check1);
+    $this->assertFalse($checkpoint_storage->exists('config_test.system'));
+    $this->assertFalse($checkpoint_storage->read('config_test.system'));
+    $this->assertNotContains('config_test.system', $checkpoint_storage->listAll());
+  }
+
+  public function testConfigRename(): void {
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+    $check1 = $checkpoint_storage->checkpoint('A');
+    $this->container->get('config.factory')->rename('config_test.dynamic.dotted.default', 'config_test.dynamic.renamed');
+    $this->config('config_test.dynamic.renamed')->set('id', 'renamed')->save();
+
+    $this->assertFalse($checkpoint_storage->exists('config_test.dynamic.renamed'));
+    $this->assertTrue($checkpoint_storage->exists('config_test.dynamic.dotted.default'));
+    $this->assertSame('dotted.default', $checkpoint_storage->read('config_test.dynamic.dotted.default')['id']);
+    $this->assertSame($checkpoint_storage->read('config_test.dynamic.dotted.default')['uuid'], $this->config('config_test.dynamic.renamed')->get('uuid'));
+
+    $check2 = $checkpoint_storage->checkpoint('B');
+    /** @var \Drupal\Core\Config\Entity\ConfigEntityStorage $storage */
+    $storage = $this->container->get('entity_type.manager')->getStorage('config_test');
+    // Entity1 will be deleted by the test.
+    $entity1 = $storage->create(
+      [
+        'id' => 'dotted.default',
+        'label' => 'Another one',
+      ]
+    );
+    $entity1->save();
+
+    $check3 = $checkpoint_storage->checkpoint('C');
+
+    $checkpoint_storage->setCheckpointToReadFrom($check2);
+    $this->assertFalse($checkpoint_storage->exists('config_test.dynamic.dotted.default'));
+
+    $checkpoint_storage->setCheckpointToReadFrom($check3);
+    $this->assertTrue($checkpoint_storage->exists('config_test.dynamic.dotted.default'));
+    $this->assertNotEquals($checkpoint_storage->read('config_test.dynamic.dotted.default')['uuid'], $this->config('config_test.dynamic.renamed')->get('uuid'));
+    $this->assertSame('Another one', $checkpoint_storage->read('config_test.dynamic.dotted.default')['label']);
+
+    $checkpoint_storage->setCheckpointToReadFrom($check1);
+    $this->assertSame('Default', $checkpoint_storage->read('config_test.dynamic.dotted.default')['label']);
+  }
+
+  public function testRevert(): void {
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+    $check1 = $checkpoint_storage->checkpoint('A');
+    $this->assertTrue($this->container->get('module_installer')->uninstall(['config_test']));
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+    $check2 = $checkpoint_storage->checkpoint('B');
+
+    $importer = $this->getConfigImporter($checkpoint_storage);
+    $config_changelist = $importer->getStorageComparer()->createChangelist()->getChangelist();
+    $this->assertContains('config_test.dynamic.dotted.default', $config_changelist['create']);
+    $this->assertSame(['core.extension'], $config_changelist['update']);
+    $this->assertSame([], $config_changelist['delete']);
+    $this->assertSame([], $config_changelist['rename']);
+
+    $importer->import();
+    $this->assertSame([], $importer->getErrors());
+
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test'));
+
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+    $checkpoint_storage->setCheckpointToReadFrom($check2);
+
+    $importer = $this->getConfigImporter($checkpoint_storage);
+    $config_changelist = $importer->getStorageComparer()->createChangelist()->getChangelist();
+    $this->assertContains('config_test.dynamic.dotted.default', $config_changelist['delete']);
+    $this->assertSame(['core.extension'], $config_changelist['update']);
+    $this->assertSame([], $config_changelist['create']);
+    $this->assertSame([], $config_changelist['rename']);
+    $importer->import();
+    $this->assertFalse($this->container->get('module_handler')->moduleExists('config_test'));
+
+    $checkpoint_storage->setCheckpointToReadFrom($check1);
+    $importer = $this->getConfigImporter($checkpoint_storage);
+    $importer->getStorageComparer()->createChangelist();
+    $importer->import();
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test'));
+  }
+
+  public function testRevertWithCollections(): void {
+    $collections = [
+      'another_collection',
+      'collection.test1',
+      'collection.test2',
+    ];
+    // Set the event listener to return three possible collections.
+    // @see \Drupal\config_collection_install_test\EventSubscriber
+    \Drupal::state()->set('config_collection_install_test.collection_names', $collections);
+
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+    $checkpoint_storage->checkpoint('A');
+
+    // Install the test module.
+    $this->assertTrue($this->container->get('module_installer')->install(['config_collection_install_test']));
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+
+    /** @var \Drupal\Core\Config\StorageInterface $active_storage */
+    $active_storage = \Drupal::service('config.storage');
+    $this->assertEquals($collections, $active_storage->getAllCollectionNames());
+    foreach ($collections as $collection) {
+      $collection_storage = $active_storage->createCollection($collection);
+      $data = $collection_storage->read('config_collection_install_test.test');
+      $this->assertEquals($collection, $data['collection']);
+    }
+
+    $check2 = $checkpoint_storage->checkpoint('B');
+
+    $importer = $this->getConfigImporter($checkpoint_storage);
+    $storage_comparer = $importer->getStorageComparer();
+    $config_changelist = $storage_comparer->createChangelist()->getChangelist();
+    $this->assertSame([], $config_changelist['create']);
+    $this->assertSame(['core.extension'], $config_changelist['update']);
+    $this->assertSame([], $config_changelist['delete']);
+    $this->assertSame([], $config_changelist['rename']);
+    foreach ($collections as $collection) {
+      $config_changelist = $storage_comparer->getChangelist(NULL, $collection);
+      $this->assertSame([], $config_changelist['create']);
+      $this->assertSame([], $config_changelist['update']);
+      $this->assertSame(['config_collection_install_test.test'], $config_changelist['delete'], $collection);
+      $this->assertSame([], $config_changelist['rename']);
+    }
+
+    $importer->import();
+    $this->assertSame([], $importer->getErrors());
+
+    $checkpoint_storage = $this->container->get('config.storage.checkpoint');
+    /** @var \Drupal\Core\Config\StorageInterface $active_storage */
+    $active_storage = \Drupal::service('config.storage');
+    $this->assertEmpty($active_storage->getAllCollectionNames());
+    foreach ($collections as $collection) {
+      $collection_storage = $active_storage->createCollection($collection);
+      $this->assertFalse($collection_storage->read('config_collection_install_test.test'));
+    }
+
+    $checkpoint_storage->setCheckpointToReadFrom($check2);
+
+    $importer = $this->getConfigImporter($checkpoint_storage);
+
+    $storage_comparer = $importer->getStorageComparer();
+    $config_changelist = $storage_comparer->createChangelist()->getChangelist();
+    $this->assertSame([], $config_changelist['create']);
+    $this->assertSame(['core.extension'], $config_changelist['update']);
+    $this->assertSame([], $config_changelist['delete']);
+    $this->assertSame([], $config_changelist['rename']);
+    foreach ($collections as $collection) {
+      $config_changelist = $storage_comparer->getChangelist(NULL, $collection);
+      $this->assertSame(['config_collection_install_test.test'], $config_changelist['create']);
+      $this->assertSame([], $config_changelist['update']);
+      $this->assertSame([], $config_changelist['delete'], $collection);
+      $this->assertSame([], $config_changelist['rename']);
+    }
+    $importer->import();
+    $this->assertSame([], $importer->getErrors());
+
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('config_collection_install_test'));
+    /** @var \Drupal\Core\Config\StorageInterface $active_storage */
+    $active_storage = \Drupal::service('config.storage');
+    $this->assertEquals($collections, $active_storage->getAllCollectionNames());
+    foreach ($collections as $collection) {
+      $collection_storage = $active_storage->createCollection($collection);
+      $data = $collection_storage->read('config_collection_install_test.test');
+      $this->assertEquals($collection, $data['collection']);
+    }
+  }
+
+  private function getConfigImporter(CheckpointStorageInterface $storage): ConfigImporter {
+    $storage_comparer = new StorageComparer(
+      $storage,
+      $this->container->get('config.storage')
+    );
+    return new ConfigImporter(
+      $storage_comparer,
+      $this->container->get('event_dispatcher'),
+      $this->container->get('config.manager'),
+      $this->container->get('lock'),
+      $this->container->get('config.typed'),
+      $this->container->get('module_handler'),
+      $this->container->get('module_installer'),
+      $this->container->get('theme_handler'),
+      $this->container->get('string_translation'),
+      $this->container->get('extension.list.module'),
+      $this->container->get('extension.list.theme')
+    );
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php b/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..700fc7d43791dbea02b51051ab4fff420e51d7d4
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/DefaultContent/AdminAccountSwitcherTest.php
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\DefaultContent;
+
+use Drupal\Core\Access\AccessException;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\DefaultContent\AdminAccountSwitcher;
+use Drupal\Core\Session\AccountSwitcherInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * @covers \Drupal\Core\DefaultContent\AdminAccountSwitcher
+ * @group DefaultContent
+ */
+class AdminAccountSwitcherTest extends KernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['system', 'user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installEntitySchema('user');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container): void {
+    parent::register($container);
+    $container->getDefinition(AdminAccountSwitcher::class)->setPublic(TRUE);
+  }
+
+  /**
+   * Tests switching to a user with an administrative role.
+   */
+  public function testSwitchToAdministrator(): void {
+    /** @var \Drupal\Core\Session\AccountInterface $account */
+    $account = $this->createUser(admin: TRUE);
+
+    $this->assertSame($account->id(), $this->container->get(AdminAccountSwitcher::class)->switchToAdministrator()->id());
+    $this->assertSame($account->id(), $this->container->get('current_user')->id());
+  }
+
+  /**
+   * Tests that there is an error if there are no administrative users.
+   */
+  public function testNoAdministratorsExist(): void {
+    /** @var \Drupal\Core\Session\AccountInterface $account */
+    $account = $this->createUser();
+    $this->assertSame(1, (int) $account->id());
+
+    $this->expectException(AccessException::class);
+    $this->expectExceptionMessage("There are no user accounts with administrative roles.");
+    $this->container->get(AdminAccountSwitcher::class)->switchToAdministrator();
+  }
+
+  /**
+   * Tests switching to user 1 when the superuser access policy is enabled.
+   */
+  public function testSuperUser(): void {
+    /** @var \Drupal\Core\Session\AccountInterface $account */
+    $account = $this->createUser();
+    $this->assertSame(1, (int) $account->id());
+
+    $switcher = new AdminAccountSwitcher(
+      $this->container->get(AccountSwitcherInterface::class),
+      $this->container->get(EntityTypeManagerInterface::class),
+      TRUE,
+    );
+    $this->assertSame(1, (int) $switcher->switchToAdministrator()->id());
+  }
+
+  public function testSwitchToAndSwitchBack(): void {
+    $this->assertTrue($this->container->get('current_user')->isAnonymous());
+
+    /** @var \Drupal\Core\Session\AccountInterface $account */
+    $account = $this->createUser();
+    $switcher = $this->container->get(AdminAccountSwitcher::class);
+    $this->assertSame($switcher, $switcher->switchTo($account));
+    $this->assertSame($account->id(), $this->container->get('current_user')->id());
+
+    $this->assertSame($switcher, $switcher->switchBack());
+    $this->assertTrue($this->container->get('current_user')->isAnonymous());
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..eef3ba5f8a6051ed50cb781b8b0cd816932fe030
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigActionValidationTest.php
@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Recipe\InvalidConfigException;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeFileException;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @group Recipe
+ */
+class ConfigActionValidationTest extends KernelTestBase {
+
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'block_content',
+    'link',
+    'node',
+    'shortcut',
+    'system',
+  ];
+
+  /**
+   * {@inheritdoc}
+   *
+   * This test requires that we save invalid config, so we can test that it gets
+   * validated after applying a recipe.
+   */
+  protected $strictConfigSchema = FALSE;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installConfig('shortcut');
+    $this->installEntitySchema('shortcut');
+  }
+
+  /**
+   * @testWith ["block_content_type"]
+   *   ["node_type"]
+   *   ["shortcut_set"]
+   *   ["menu"]
+   */
+  public function testConfigActionsAreValidated(string $entity_type_id): void {
+    /** @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface $storage */
+    $storage = $this->container->get(EntityTypeManagerInterface::class)
+      ->getStorage($entity_type_id);
+
+    /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
+    $entity_type = $storage->getEntityType();
+    // If there is a label key, it's safe to assume that it's not allowed to be
+    // empty. We don't care whether it's immutable; we just care that the value
+    // the config action sets it to (an empty string) violates config schema.
+    $label_key = $entity_type->getKey('label');
+    $this->assertNotEmpty($label_key);
+    $entity = $storage->create([
+      $entity_type->getKey('id') => 'test',
+      $label_key => 'Test',
+    ]);
+    $entity->save();
+
+    $config_name = $entity->getConfigDependencyName();
+    $recipe_data = <<<YAML
+name: Config actions making bad decisions
+config:
+  actions:
+    $config_name:
+      simple_config_update:
+        $label_key: ''
+YAML;
+
+    $recipe = $this->createRecipe($recipe_data);
+    try {
+      RecipeRunner::processRecipe($recipe);
+      $this->fail('An exception should have been thrown.');
+    }
+    catch (InvalidConfigException $e) {
+      $this->assertCount(1, $e->violations);
+      $violation = $e->violations->get(0);
+      $this->assertSame($label_key, $violation->getPropertyPath());
+      $this->assertSame("This value should not be blank.", (string) $violation->getMessage());
+    }
+  }
+
+  /**
+   * Tests validating that config actions' dependencies are present.
+   *
+   * Tests that the all of the config listed in a recipe's config actions are
+   * provided by extensions that will be installed by the recipe, or one of its
+   * dependencies (no matter how deeply nested).
+   *
+   * @testWith ["direct_dependency"]
+   *   ["indirect_dependency_one_level_down"]
+   *   ["indirect_dependency_two_levels_down"]
+   */
+  public function testConfigActionDependenciesAreValidated(string $name): void {
+    Recipe::createFromDirectory("core/tests/fixtures/recipes/config_actions_dependency_validation/$name");
+  }
+
+  /**
+   * Tests config action validation for missing dependency.
+   */
+  public function testConfigActionMissingDependency(): void {
+    $recipe_data = <<<YAML
+name: Config actions making bad decisions
+config:
+  actions:
+    random.config:
+      simple_config_update:
+        label: ''
+YAML;
+
+    try {
+      $this->createRecipe($recipe_data);
+      $this->fail('An exception should have been thrown.');
+    }
+    catch (RecipeFileException $e) {
+      $this->assertIsObject($e->violations);
+      $this->assertCount(1, $e->violations);
+      $this->assertSame('[config][actions][random.config]', $e->violations[0]->getPropertyPath());
+      $this->assertSame("Config actions cannot be applied to random.config because the random extension is not installed, and is not installed by this recipe or any of the recipes it depends on.", (string) $e->violations[0]->getMessage());
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b91ea75b37b42a96d205b286a732089d3acf6def
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigConfiguratorTest.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\Core\Recipe\ConfigConfigurator
+ * @group Recipe
+ */
+class ConfigConfiguratorTest extends KernelTestBase {
+
+  public function testExistingConfigWithKeysInDifferentOrder(): void {
+    $recipe_dir = uniqid('public://recipe_test_');
+    mkdir($recipe_dir . '/config', recursive: TRUE);
+
+    $this->enableModules(['system']);
+    $this->installConfig('system');
+    /** @var mixed[][] $original_data */
+    $original_data = $this->config('system.site')->get();
+    // Remove keys that are ignored during the comparison.
+    unset($original_data['uuid'], $original_data['_core']);
+    $recipe_data = $original_data;
+    // Reorder an inner array, to ensure keys are sorted recursively.
+    $recipe_data['page'] = array_reverse($original_data['page'], TRUE);
+    $this->assertNotSame($original_data, $recipe_data);
+    file_put_contents($recipe_dir . '/config/system.site.yml', Yaml::encode($recipe_data));
+
+    $recipe = [
+      'name' => 'Same config, different order',
+      'type' => 'Testing',
+    ];
+    file_put_contents($recipe_dir . '/recipe.yml', Yaml::encode($recipe));
+
+    // If there was a conflict with the pre-existing config, ConfigConfigurator
+    // would throw an exception and the recipe would not be created. So all we
+    // need to do here is assert that, in fact, we were able to create a recipe
+    // object.
+    $this->assertInstanceOf(Recipe::class, Recipe::createFromDirectory($recipe_dir));
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d6119703c913f3b6558d5225e5e86c47a11f2e8
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/ConfigValidationTest.php
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Recipe\InvalidConfigException;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @group Recipe
+ */
+class ConfigValidationTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   *
+   * This test depends on us being able to create invalid config, so we can
+   * ensure that validatable config is validated by the recipe runner.
+   */
+  protected $strictConfigSchema = FALSE;
+
+  /**
+   * Creates a recipe with invalid config data in a particular file.
+   *
+   * @param string $file
+   *   The name of the file (in the recipe's `config` directory) which should
+   *   have invalid data.
+   *
+   * @return \Drupal\Core\Recipe\Recipe
+   *   A wrapper around the created recipe.
+   */
+  private function createRecipeWithInvalidDataInFile(string $file): Recipe {
+    $dir = uniqid('public://');
+    mkdir($dir . '/config', recursive: TRUE);
+
+    $data = file_get_contents($this->getDrupalRoot() . '/core/modules/config/tests/config_test/config/install/config_test.types.yml');
+    assert(is_string($data));
+    $data = Yaml::decode($data);
+    // The `array` key needs to be an array, not an integer. If the config is
+    // validated, this will raise a validation error.
+    /** @var mixed[] $data */
+    $data['array'] = 39;
+    file_put_contents($dir . '/config/' . $file, Yaml::encode($data));
+
+    $recipe = <<<YAML
+name: Config validation test
+install:
+  - config_test
+YAML;
+    file_put_contents($dir . '/recipe.yml', $recipe);
+
+    return Recipe::createFromDirectory($dir);
+  }
+
+  /**
+   * Tests that the recipe runner only validates config which is validatable.
+   */
+  public function testValidatableConfigIsValidated(): void {
+    // Since config_test.types is not validatable, there should not be a
+    // validation error.
+    $recipe = $this->createRecipeWithInvalidDataInFile('config_test.types.yml');
+    RecipeRunner::processRecipe($recipe);
+    $this->assertFalse($this->config('config_test.types')->isNew());
+
+    // If we create a config object which IS fully validatable, and has invalid
+    // data, we should get a validation error.
+    $recipe = $this->createRecipeWithInvalidDataInFile('config_test.types.fully_validatable.yml');
+    $this->expectException(InvalidConfigException::class);
+    $this->expectExceptionMessage('There were validation errors in config_test.types.fully_validatable');
+    RecipeRunner::processRecipe($recipe);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5590fe0e75ad6a020c81d0601000bceee2b43053
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/EntityMethodConfigActionsTest.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+
+/**
+ * @group Recipe
+ */
+class EntityMethodConfigActionsTest extends KernelTestBase {
+
+  use ContentTypeCreationTrait;
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'field',
+    'layout_builder',
+    'layout_discovery',
+    'node',
+    'system',
+    'text',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->installConfig('node');
+    $this->createContentType(['type' => 'test']);
+
+    $this->container->get(EntityDisplayRepositoryInterface::class)
+      ->getViewDisplay('node', 'test', 'full')
+      ->save();
+  }
+
+  public function testSetSingleThirdPartySetting(): void {
+    $recipe = <<<YAML
+name: Third-party setting
+config:
+  actions:
+    core.entity_view_display.node.test.full:
+      setThirdPartySetting:
+        module: layout_builder
+        key: enabled
+        value: true
+YAML;
+    $recipe = $this->createRecipe($recipe);
+    RecipeRunner::processRecipe($recipe);
+
+    /** @var \Drupal\Core\Config\Entity\ThirdPartySettingsInterface $display */
+    $display = $this->container->get(EntityDisplayRepositoryInterface::class)
+      ->getViewDisplay('node', 'test', 'full');
+    $this->assertTrue($display->getThirdPartySetting('layout_builder', 'enabled'));
+  }
+
+  public function testSetMultipleThirdPartySettings(): void {
+    $recipe = <<<YAML
+name: Third-party setting
+config:
+  actions:
+    core.entity_view_display.node.test.full:
+      setThirdPartySettings:
+        -
+          module: layout_builder
+          key: enabled
+          value: true
+        -
+          module: layout_builder
+          key: allow_custom
+          value: true
+YAML;
+    $recipe = $this->createRecipe($recipe);
+    RecipeRunner::processRecipe($recipe);
+
+    /** @var \Drupal\Core\Config\Entity\ThirdPartySettingsInterface $display */
+    $display = $this->container->get(EntityDisplayRepositoryInterface::class)
+      ->getViewDisplay('node', 'test', 'full');
+    $this->assertTrue($display->getThirdPartySetting('layout_builder', 'enabled'));
+    $this->assertTrue($display->getThirdPartySetting('layout_builder', 'allow_custom'));
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fa9f9f3c31d88abe0f7d859cf807408e933f5ae6
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/InstallConfiguratorTest.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Core\Recipe\InstallConfigurator;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\Core\Recipe\InstallConfigurator
+ * @group Recipe
+ */
+class InstallConfiguratorTest extends KernelTestBase {
+
+  public function testDependenciesAreAutomaticallyIncluded(): void {
+    $configurator = new InstallConfigurator(
+      ['node', 'test_theme_depending_on_modules'],
+      $this->container->get(ModuleExtensionList::class),
+      $this->container->get(ThemeExtensionList::class),
+    );
+
+    // Node and its dependencies should be listed.
+    $this->assertContains('node', $configurator->modules);
+    $this->assertContains('text', $configurator->modules);
+    $this->assertContains('field', $configurator->modules);
+    $this->assertContains('filter', $configurator->modules);
+    // The test theme, along with its module AND theme dependencies, should be
+    // listed.
+    $this->assertContains('test_theme_depending_on_modules', $configurator->themes);
+    $this->assertContains('test_module_required_by_theme', $configurator->modules);
+    $this->assertContains('test_another_module_required_by_theme', $configurator->modules);
+    $this->assertContains('stark', $configurator->themes);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c9465a31d49f84aa5933e715fcede6241b641d9d
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/PermissionsPerBundleTest.php
@@ -0,0 +1,231 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+
+/**
+ * @covers \Drupal\Core\Config\Action\Plugin\ConfigAction\PermissionsPerBundle
+ * @covers \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\PermissionsPerBundleDeriver
+ *
+ * @group Recipe
+ */
+class PermissionsPerBundleTest extends KernelTestBase {
+
+  use ContentTypeCreationTrait;
+  use MediaTypeCreationTrait;
+  use RecipeTestTrait;
+  use TaxonomyTestTrait;
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'field',
+    'media',
+    'media_test_source',
+    'node',
+    'system',
+    'taxonomy',
+    'text',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installConfig('node');
+
+    $this->createRole([], 'super_editor');
+
+    $this->createContentType(['type' => 'article']);
+    $this->createContentType(['type' => 'blog']);
+    $this->createContentType(['type' => 'landing_page']);
+
+    $this->createMediaType('test', ['id' => 'beautiful']);
+    $this->createMediaType('test', ['id' => 'controversial']);
+    $this->createMediaType('test', ['id' => 'special']);
+
+    $this->createVocabulary(['vid' => 'tags']);
+    $this->createVocabulary(['vid' => 'categories']);
+  }
+
+  /**
+   * Tests granting multiple bundle-specific permissions.
+   */
+  public function testGrantPermissionsPerBundle(): void {
+    $recipe_data = <<<YAML
+name: 'Multi permissions!'
+config:
+  actions:
+    user.role.super_editor:
+      grantPermissionsForEachNodeType:
+        - create %bundle content
+        - edit own %bundle content
+      grantPermissionsForEachMediaType:
+        permissions:
+          - create %bundle media
+          - edit own %bundle media
+      grantPermissionsForEachTaxonomyVocabulary: create terms in %bundle
+YAML;
+    $this->applyRecipeFromString($recipe_data);
+
+    $expected_permissions = [
+      'create article content',
+      'create blog content',
+      'create landing_page content',
+      'edit own article content',
+      'edit own blog content',
+      'edit own landing_page content',
+      'create beautiful media',
+      'create controversial media',
+      'create special media',
+      'edit own beautiful media',
+      'edit own controversial media',
+      'edit own special media',
+      'create terms in tags',
+      'create terms in categories',
+    ];
+    $role = Role::load('super_editor');
+    assert($role instanceof RoleInterface);
+    foreach ($expected_permissions as $permission) {
+      $this->assertTrue($role->hasPermission($permission));
+    }
+  }
+
+  /**
+   * Tests that the permissions-per-bundle action can only be applied to roles.
+   */
+  public function testActionIsOnlyAvailableToUserRoles(): void {
+    $recipe_data = <<<YAML
+name: 'Only for roles...'
+config:
+  actions:
+    field.storage.node.body:
+      grantPermissionsForEachNodeType:
+        - create %bundle content
+        - edit own %bundle content
+YAML;
+
+    $this->expectException(PluginNotFoundException::class);
+    $this->expectExceptionMessage('The "grantPermissionsForEachNodeType" plugin does not exist.');
+    $this->applyRecipeFromString($recipe_data);
+  }
+
+  /**
+   * Tests granting permissions for one bundle, then all of them.
+   */
+  public function testGrantPermissionsOnOneBundleThenAll(): void {
+    $recipe_data = <<<YAML
+name: 'All bundles except one'
+config:
+  actions:
+    user.role.super_editor:
+      grantPermissions:
+        - create beautiful media
+        - edit own beautiful media
+      grantPermissionsForEachMediaType:
+        - create %bundle media
+        - edit own %bundle media
+YAML;
+    $this->applyRecipeFromString($recipe_data);
+
+    $role = Role::load('super_editor');
+    $this->assertInstanceOf(Role::class, $role);
+    $this->assertTrue($role->hasPermission('create beautiful media'));
+    $this->assertTrue($role->hasPermission('edit own beautiful media'));
+    $this->assertTrue($role->hasPermission('create controversial media'));
+    $this->assertTrue($role->hasPermission('edit own beautiful media'));
+  }
+
+  /**
+   * Tests granting permissions for all bundles except certain ones.
+   */
+  public function testGrantPermissionsToAllBundlesExceptSome(): void {
+    $recipe_data = <<<YAML
+name: 'Bundle specific permissions with some exceptions'
+config:
+  actions:
+    user.role.super_editor:
+      grantPermissionsForEachNodeType:
+        permissions:
+          - view %bundle revisions
+        except:
+          - article
+          - blog
+      grantPermissionsForEachMediaType:
+        permissions: view any %bundle media revisions
+        except:
+          - controversial
+      grantPermissionsForEachTaxonomyVocabulary:
+        permissions:
+          - view term revisions in %bundle
+        except: tags
+YAML;
+    $this->applyRecipeFromString($recipe_data);
+
+    $role = Role::load('super_editor');
+    $this->assertInstanceOf(Role::class, $role);
+    $this->assertTrue($role->hasPermission('view landing_page revisions'));
+    $this->assertFalse($role->hasPermission('view article revisions'));
+    $this->assertFalse($role->hasPermission('view blog revisions'));
+    $this->assertTrue($role->hasPermission('view any beautiful media revisions'));
+    $this->assertTrue($role->hasPermission('view any special media revisions'));
+    $this->assertFalse($role->hasPermission('view any controversial media revisions'));
+    $this->assertTrue($role->hasPermission('view term revisions in categories'));
+    $this->assertFalse($role->hasPermission('view term revisions in tags'));
+  }
+
+  /**
+   * Tests that there is an exception if the permission templates are invalid.
+   *
+   * @param mixed $value
+   *   The permission template which should raise an error.
+   *
+   * @testWith [["a %Bundle permission"]]
+   *   [""]
+   *   [[]]
+   */
+  public function testInvalidValue(mixed $value): void {
+    $value = Json::encode($value);
+
+    $recipe_data = <<<YAML
+name: 'Bad permission value'
+config:
+  actions:
+    user.role.super_editor:
+      grantPermissionsForEachMediaType: $value
+YAML;
+    $this->expectException(ConfigActionException::class);
+    $this->expectExceptionMessage(" must be an array of strings that contain '%bundle'.");
+    $this->applyRecipeFromString($recipe_data);
+  }
+
+  /**
+   * Given a string of `recipe.yml` contents, applies it to the site.
+   *
+   * @param string $recipe_data
+   *   The contents of `recipe.yml`.
+   */
+  private function applyRecipeFromString(string $recipe_data): void {
+    $recipe = $this->createRecipe($recipe_data);
+    RecipeRunner::processRecipe($recipe);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..640cc182de87cc155d326f16cbe1594b04df26c9
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeConfiguratorTest.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeConfigurator;
+use Drupal\Core\Recipe\RecipeDiscovery;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\Core\Recipe\RecipeConfigurator
+ * @group Recipe
+ */
+class RecipeConfiguratorTest extends KernelTestBase {
+
+  public function testRecipeConfigurator(): void {
+    $discovery = new RecipeDiscovery('core/tests/fixtures/recipes');
+    $recipe_configurator = new RecipeConfigurator(
+      ['install_two_modules', 'install_node_with_config', 'recipe_include'],
+      $discovery
+    );
+    // Private method "listAllRecipes".
+    $reflection = new \ReflectionMethod('\Drupal\Core\Recipe\RecipeConfigurator', 'listAllRecipes');
+
+    // Test methods.
+    /** @var \Drupal\Core\Recipe\Recipe[] $recipes */
+    $recipes = (array) $reflection->invoke($recipe_configurator);
+    $recipes_names = array_map(fn(Recipe $recipe) => $recipe->name, $recipes);
+    $recipe_extensions = $recipe_configurator->listAllExtensions();
+    $expected_recipes_names = [
+      'Install two modules',
+      'Install node with config',
+      'Recipe include',
+    ];
+    $expected_recipe_extensions = [
+      'system',
+      'user',
+      'filter',
+      'field',
+      'text',
+      'node',
+      'dblog',
+    ];
+
+    $this->assertEquals($expected_recipes_names, $recipes_names);
+    $this->assertEquals($expected_recipe_extensions, $recipe_extensions);
+    $this->assertEquals(1, array_count_values($recipes_names)['Install node with config']);
+    $this->assertEquals(1, array_count_values($recipe_extensions)['field']);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..165fb07de23691a9e2f6b189e4c727ddded7ba16
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Recipe\RecipeDiscovery;
+use Drupal\Core\Recipe\UnknownRecipeException;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeDiscovery
+ * @group Recipe
+ */
+class RecipeDiscoveryTest extends KernelTestBase {
+
+  /**
+   * Tests that recipe discovery can find recipes.
+   *
+   * @testWith ["install_two_modules", "Install two modules"]
+   *           ["recipe_include", "Recipe include"]
+   */
+  public function testRecipeDiscovery(string $recipe, string $name): void {
+    $discovery = new RecipeDiscovery('core/tests/fixtures/recipes');
+    $recipe = $discovery->getRecipe($recipe);
+    $this->assertSame($name, $recipe->name);
+  }
+
+  /**
+   * Tests the exception thrown when recipe discovery cannot find a recipe.
+   *
+   * @testWith ["no_recipe"]
+   *           ["does_not_exist"]
+   */
+  public function testRecipeDiscoveryException(string $recipe): void {
+    $discovery = new RecipeDiscovery('core/tests/fixtures/recipes');
+    try {
+      $discovery->getRecipe($recipe);
+      $this->fail('Expected exception not thrown');
+    }
+    catch (UnknownRecipeException $e) {
+      $this->assertSame($recipe, $e->recipe);
+      $this->assertSame('core/tests/fixtures/recipes', $e->searchPath);
+      $this->assertSame('Can not find the ' . $recipe . ' recipe, search path: ' . $e->searchPath, $e->getMessage());
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..20d12bae4c3852bd59931435dded77d87f2de930
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeEventsTest.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeAppliedEvent;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\KernelTests\KernelTestBase;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * @group Recipe
+ */
+class RecipeEventsTest extends KernelTestBase implements EventSubscriberInterface {
+
+  /**
+   * The human-readable names of the recipes that have been applied.
+   *
+   * @var string[]
+   */
+  private array $recipesApplied = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      RecipeAppliedEvent::class => 'onRecipeApply',
+    ];
+  }
+
+  public function onRecipeApply(RecipeAppliedEvent $event): void {
+    $this->recipesApplied[] = $event->recipe->name;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container): void {
+    parent::register($container);
+
+    // Every time the container is rebuilt, ensure this object is subscribing to
+    // events.
+    $container->getDefinition('event_dispatcher')
+      ->addMethodCall('addSubscriber', [$this]);
+  }
+
+  public function testRecipeAppliedEvent(): void {
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include');
+    RecipeRunner::processRecipe($recipe);
+
+    $this->assertSame(['Install node with config', 'Recipe include'], $this->recipesApplied);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0af066b626cde69a59b94efc548b1bfbbf6f4e49
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php
@@ -0,0 +1,254 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipePreExistingConfigException;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\NodeType;
+use Drupal\views\Entity\View;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeRunner
+ * @group Recipe
+ */
+class RecipeRunnerTest extends KernelTestBase {
+
+  use RecipeTestTrait;
+
+  public function testModuleInstall(): void {
+    // Test the state prior to applying the recipe.
+    $this->assertFalse($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is not installed');
+    $this->assertFalse($this->container->get('module_handler')->moduleExists('text'), 'The text module is not installed');
+    $this->assertFalse($this->container->get('module_handler')->moduleExists('node'), 'The node module is not installed');
+    $this->assertFalse($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration does not exist');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_two_modules');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after applying the recipe.
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is installed');
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('text'), 'The text module is installed');
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed');
+    $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
+    $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE');
+  }
+
+  public function testModuleAndThemeInstall(): void {
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/base_theme_and_views');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after applying the recipe.
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('views'), 'The views module is installed');
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed');
+    $this->assertTrue($this->container->get('theme_handler')->themeExists('test_basetheme'), 'The test_basetheme theme is installed');
+    $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subtheme'), 'The test_subtheme theme is installed');
+    $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subsubtheme'), 'The test_subsubtheme theme is installed');
+    $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
+    $this->assertFalse($this->container->get('config.storage')->exists('views.view.archive'), 'The views.view.archive configuration has not been created');
+    $this->assertEmpty(View::loadMultiple(), "No views exist");
+  }
+
+  public function testThemeModuleDependenciesInstall(): void {
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/theme_with_module_dependencies');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after applying the recipe.
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('test_module_required_by_theme'), 'The test_module_required_by_theme module is installed');
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('test_another_module_required_by_theme'), 'The test_another_module_required_by_theme module is installed');
+    $this->assertTrue($this->container->get('theme_handler')->themeExists('test_theme_depending_on_modules'), 'The test_theme_depending_on_modules theme is installed');
+  }
+
+  public function testModuleConfigurationOverride(): void {
+    // Test the state prior to applying the recipe.
+    $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after applying the recipe.
+    $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
+    $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
+    $this->assertTrue($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to TRUE');
+    $this->assertSame('Test content type', NodeType::load('test')?->label());
+    $node_type_data = $this->config('node.type.test')->get();
+    $this->assertGreaterThan(0, strlen($node_type_data['uuid']), 'The node type configuration has been assigned a UUID.');
+    // cSpell:disable-next-line
+    $this->assertSame('8Jlq8CmNXHVtNIHBHgFGpnAKthlUz0XoW_D0g56QXqY', $node_type_data['_core']['default_config_hash']);
+  }
+
+  public function testApplySameRecipe(): void {
+    // Test the state prior to applying the recipe.
+    $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state prior to applying the recipe.
+    $this->assertNotEmpty($this->container->get('config.factory')->listAll('node.'), 'There is node configuration');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+    RecipeRunner::processRecipe($recipe);
+    $this->assertTrue(TRUE, 'Applying a recipe for the second time with no config changes results in a successful application');
+
+    $type = NodeType::load('test');
+    $type->setNewRevision(FALSE);
+    $type->save();
+
+    $this->expectException(RecipePreExistingConfigException::class);
+    $this->expectExceptionMessage("The configuration 'node.type.test' exists already and does not match the recipe's configuration");
+    Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+  }
+
+  public function testConfigFromModule(): void {
+    // Test the state prior to applying the recipe.
+    $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after to applying the recipe.
+    $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
+    $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
+    $this->assertSame(['dotted.default', 'override'], array_keys($config_test_entities));
+  }
+
+  public function testConfigWildcard(): void {
+    // Test the state prior to applying the recipe.
+    $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_wildcard');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after to applying the recipe.
+    $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
+    $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
+    $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities));
+    $this->assertSame('Default', $config_test_entities['dotted.default']->label());
+    $this->assertSame('herp', $this->config('config_test.system')->get('404'));
+  }
+
+  public function testConfigFromModuleAndRecipe(): void {
+    // Test the state prior to applying the recipe.
+    $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module_and_recipe');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after to applying the recipe.
+    $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
+    $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
+    $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities));
+    $this->assertSame('Provided by recipe', $config_test_entities['dotted.default']->label());
+    $this->assertSame('foo', $this->config('config_test.system')->get('404'));
+  }
+
+  public function testRecipeInclude(): void {
+    // Test the state prior to applying the recipe.
+    $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
+    $this->assertFalse($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module not installed');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after to applying the recipe.
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module installed');
+    $this->assertSame('Test content type', NodeType::load('test')?->label());
+    $this->assertSame('Another test content type', NodeType::load('another_test')?->label());
+  }
+
+  public function testConfigActions() :void {
+    // Test the state prior to applying the recipe.
+    $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after to applying the recipe.
+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+    $config_test_entity = $storage->load('recipe');
+    $this->assertInstanceOf(ConfigTest::class, $config_test_entity);
+    $this->assertSame('Created by recipe', $config_test_entity->label());
+    $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty());
+    $this->assertSame('not bar', $this->config('config_test.system')->get('foo'));
+  }
+
+  public function testConfigActionsPreExistingConfig() :void {
+    $this->enableModules(['config_test']);
+    $this->installConfig(['config_test']);
+    $this->assertSame('bar', $this->config('config_test.system')->get('foo'));
+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
+    $config_test_entity = $storage->create(['id' => 'recipe', 'label' => 'Created by test']);
+    $this->assertInstanceOf(ConfigTest::class, $config_test_entity);
+    $config_test_entity->setProtectedProperty('Set by test');
+    $config_test_entity->save();
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after to applying the recipe.
+    $config_test_entity = $storage->load('recipe');
+    $this->assertInstanceOf(ConfigTest::class, $config_test_entity);
+    $this->assertSame('Created by test', $config_test_entity->label());
+    $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty());
+    $this->assertSame('not bar', $this->config('config_test.system')->get('foo'));
+  }
+
+  public function testInvalidConfigAction() :void {
+    $recipe_data = <<<YAML
+name: Invalid config action
+install:
+  - config_test
+config:
+  actions:
+    config_test.dynamic.recipe:
+      ensure_exists:
+        label: 'Created by recipe'
+      setBody: 'Description set by recipe'
+YAML;
+
+    $recipe = $this->createRecipe($recipe_data);
+    $this->expectException(PluginNotFoundException::class);
+    $this->expectExceptionMessage('The "setBody" plugin does not exist.');
+    RecipeRunner::processRecipe($recipe);
+  }
+
+  public function testRecipesAreDisambiguatedByPath(): void {
+    $recipe_data = <<<YAML
+name: 'Recipe include'
+recipes:
+  - core/tests/fixtures/recipes/recipe_include
+install:
+  - config_test
+YAML;
+
+    $recipe = $this->createRecipe($recipe_data, 'recipe_include');
+    RecipeRunner::processRecipe($recipe);
+
+    // Test the state after to applying the recipe.
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module installed');
+    $this->assertTrue($this->container->get('module_handler')->moduleExists('config_test'), 'Config test module installed');
+    $this->assertSame('Test content type', NodeType::load('test')?->label());
+    $this->assertSame('Another test content type', NodeType::load('another_test')?->label());
+
+    $operations = RecipeRunner::toBatchOperations($recipe);
+    $this->assertSame('triggerEvent', $operations[7][0][1]);
+    $this->assertSame('Install node with config', $operations[7][1][0]->name);
+    $this->assertStringEndsWith('core/tests/fixtures/recipes/install_node_with_config', $operations[7][1][0]->path);
+
+    $this->assertSame('triggerEvent', $operations[10][0][1]);
+    $this->assertSame('Recipe include', $operations[10][1][0]->name);
+    $this->assertStringEndsWith('core/tests/fixtures/recipes/recipe_include', $operations[10][1][0]->path);
+
+    $this->assertSame('triggerEvent', $operations[12][0][1]);
+    $this->assertSame('Recipe include', $operations[12][1][0]->name);
+    $this->assertSame($this->siteDirectory . '/recipes/recipe_include', $operations[12][1][0]->path);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7df23e467ac25893a2c5525d0813fd8376f91cce
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeFileException;
+use Drupal\Core\Recipe\RecipePreExistingConfigException;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\Recipe
+ * @group Recipe
+ */
+class RecipeTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['system', 'user', 'field'];
+
+  /**
+   * @testWith ["no_extensions", "No extensions" , "Testing", [], "A recipe description"]
+   *           ["install_two_modules", "Install two modules" , "Content type", ["filter", "text", "node"], ""]
+   */
+  public function testCreateFromDirectory2(string $recipe_name, string $expected_name, string $expected_type, array $expected_modules, string $expected_description): void {
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/' . $recipe_name);
+    $this->assertSame($expected_name, $recipe->name);
+    $this->assertSame($expected_type, $recipe->type);
+    $this->assertSame($expected_modules, $recipe->install->modules);
+    $this->assertSame($expected_description, $recipe->description);
+  }
+
+  public function testCreateFromDirectoryNoRecipe(): void {
+    $dir = uniqid('public://');
+    mkdir($dir);
+
+    $this->expectException(RecipeFileException::class);
+    $this->expectExceptionMessage('There is no ' . $dir . '/recipe.yml file');
+    Recipe::createFromDirectory($dir);
+  }
+
+  public function testPreExistingDifferentConfiguration(): void {
+    // Install the node module, its dependencies and configuration.
+    $this->container->get('module_installer')->install(['node']);
+    $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE');
+
+    try {
+      Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+      $this->fail('Expected exception not thrown');
+    }
+    catch (RecipePreExistingConfigException $e) {
+      $this->assertSame("The configuration 'node.settings' exists already and does not match the recipe's configuration", $e->getMessage());
+      $this->assertSame('node.settings', $e->configName);
+    }
+  }
+
+  public function testPreExistingMatchingConfiguration(): void {
+    // Install the node module, its dependencies and configuration.
+    $this->container->get('module_installer')->install(['node']);
+    // Change the config to match the recipe's config to prevent the exception
+    // being thrown.
+    $this->config('node.settings')->set('use_admin_theme', TRUE)->save();
+
+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
+    $this->assertSame('core/tests/fixtures/recipes/install_node_with_config/config', $recipe->config->recipeConfigDirectory);
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..20551fb972fff7c4fbd47491a15be8179e52f3b4
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeValidationTest.php
@@ -0,0 +1,340 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeFileException;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @group Recipe
+ */
+class RecipeValidationTest extends KernelTestBase {
+
+  /**
+   * Data provider for ::testRecipeValidation().
+   *
+   * @return \Generator
+   *   The test cases.
+   */
+  public static function providerRecipeValidation(): iterable {
+    yield 'name is correct' => [
+      'name: Correct name',
+      NULL,
+    ];
+    yield 'name missing' => [
+      '{}',
+      [
+        '[name]' => ['This field is missing.'],
+      ],
+    ];
+    yield 'name is not a string' => [
+      'name: 39',
+      [
+        '[name]' => ['This value should be of type string.'],
+      ],
+    ];
+    yield 'name is null' => [
+      'name: ~',
+      [
+        '[name]' => ['This value should not be blank.'],
+      ],
+    ];
+    yield 'name is blank' => [
+      "name: ''",
+      [
+        '[name]' => ['This value should not be blank.'],
+      ],
+    ];
+    yield 'name has invalid characters' => [
+      <<<YAML
+name: |
+  My
+  Amazing Recipe
+YAML,
+      [
+        '[name]' => ['Recipe names cannot span multiple lines or contain control characters.'],
+      ],
+    ];
+    yield 'description is correct' => [
+      <<<YAML
+name: Correct description
+description: 'This is the correct description of a recipe.'
+YAML,
+      NULL,
+    ];
+    yield 'description is not a string' => [
+      <<<YAML
+name: Bad description
+description: [Nope!]
+YAML,
+      [
+        '[description]' => ['This value should be of type string.'],
+      ],
+    ];
+    yield 'description is blank' => [
+      <<<YAML
+name: Blank description
+description: ''
+YAML,
+      [
+        '[description]' => ['This value should not be blank.'],
+      ],
+    ];
+    yield 'description is null' => [
+      <<<YAML
+name: Null description
+description: ~
+YAML,
+      [
+        '[description]' => ['This value should not be blank.'],
+      ],
+    ];
+    yield 'description contains control characters' => [
+      <<<YAML
+name: Bad description
+description: "I have a\b bad character."
+YAML,
+      [
+        '[description]' => ['The recipe description cannot contain control characters, only visible characters.'],
+      ],
+    ];
+    yield 'type is correct' => [
+      <<<YAML
+name: Correct type
+type: Testing
+YAML,
+      NULL,
+    ];
+    yield 'type is not a string' => [
+      <<<YAML
+name: Bad type
+type: 39
+YAML,
+      [
+        '[type]' => ['This value should be of type string.'],
+      ],
+    ];
+    yield 'type is blank' => [
+      <<<YAML
+name: Blank type
+type: ''
+YAML,
+      [
+        '[type]' => ['This value should not be blank.'],
+      ],
+    ];
+    yield 'type is null' => [
+      <<<YAML
+name: Null type
+type: ~
+YAML,
+      [
+        '[type]' => ['This value should not be blank.'],
+      ],
+    ];
+    yield 'type has invalid characters' => [
+      <<<YAML
+name: Invalid type
+type: |
+  My
+  Amazing Recipe
+YAML,
+      [
+        '[type]' => ['Recipe type cannot span multiple lines or contain control characters.'],
+      ],
+    ];
+    // @todo Test valid recipe once https://www.drupal.org/i/3421197 is in.
+    yield 'recipes list is scalar' => [
+      <<<YAML
+name: Bad recipe list
+recipes: 39
+YAML,
+      [
+        '[recipes]' => ['This value should be of type iterable.'],
+      ],
+    ];
+    yield 'recipes list has a blank entry' => [
+      <<<YAML
+name: Invalid recipe
+recipes: ['']
+YAML,
+      [
+        '[recipes][0]' => ['This value should not be blank.'],
+      ],
+    ];
+    yield 'recipes list has a non-existent recipe' => [
+      <<<YAML
+name: Non-existent recipe
+recipes:
+  - vaporware
+YAML,
+      [
+        '[recipes][0]' => ['The vaporware recipe does not exist.'],
+      ],
+    ];
+    yield 'recipe depends on itself' => [
+      <<<YAML
+name: 'Inception'
+recipes:
+  - no_extensions
+YAML,
+      [
+        '[recipes][0]' => ['The "no_extensions" recipe cannot depend on itself.'],
+      ],
+      'no_extensions',
+    ];
+    yield 'extension list is scalar' => [
+      <<<YAML
+name: Bad extension list
+install: 39
+YAML,
+      [
+        '[install]' => ['This value should be of type iterable.'],
+      ],
+    ];
+    yield 'extension list has a blank entry' => [
+      <<<YAML
+name: Blank extension list
+install: ['']
+YAML,
+      [
+        '[install][0]' => ['This value should not be blank.'],
+      ],
+    ];
+    yield 'installing unknown extensions' => [
+      <<<YAML
+name: 'Unknown extensions'
+install:
+  - config test
+  - drupal:color
+YAML,
+      [
+        '[install][0]' => ['"config test" is not a known module or theme.'],
+        '[install][1]' => ['"color" is not a known module or theme.'],
+      ],
+    ];
+    yield 'only installs extension' => [
+      <<<YAML
+name: 'Only installs extensions'
+install:
+  - filter
+  - drupal:claro
+YAML,
+      NULL,
+    ];
+    yield 'config import list is valid' => [
+      <<<YAML
+name: 'Correct config import list'
+config:
+  import:
+    config_test: '*'
+    claro:
+      - claro.settings
+YAML,
+      NULL,
+    ];
+    yield 'config import list is scalar' => [
+      <<<YAML
+name: 'Bad config import list'
+config:
+  import: 23
+YAML,
+      [
+        '[config][import]' => ['This value should be of type iterable.'],
+      ],
+    ];
+    yield 'config import list has a blank entry' => [
+      <<<YAML
+name: Blank config import list
+config:
+  import: ['']
+YAML,
+      [
+        '[config][import][0]' => ['This value should satisfy at least one of the following constraints: [1] This value should be identical to string "*". [2] Each element of this collection should satisfy its own set of constraints.'],
+      ],
+    ];
+    yield 'config actions list is valid' => [
+      <<<YAML
+name: 'Correct config actions list'
+install:
+  - config_test
+config:
+  actions:
+    config_test.dynamic.recipe:
+      ensure_exists:
+        label: 'Created by recipe'
+      setProtectedProperty: 'Set by recipe'
+YAML,
+      NULL,
+    ];
+    yield 'config actions list is scalar' => [
+      <<<YAML
+name: 'Bad config actions list'
+config:
+  actions: 23
+YAML,
+      [
+        '[config][actions]' => ['This value should be of type iterable.'],
+      ],
+    ];
+    yield 'config actions list has a blank entry' => [
+      <<<YAML
+name: Blank config actions list
+config:
+  actions: ['']
+YAML,
+      [
+        '[config][actions][0]' => [
+          'This value should be of type array.',
+          'This value should not be blank.',
+          'Config actions cannot be applied to 0 because the 0 extension is not installed, and is not installed by this recipe or any of the recipes it depends on.',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests the validation of recipe.yml file.
+   *
+   * @param string $recipe
+   *   The contents of the `recipe.yml` file.
+   * @param string[][]|null $expected_violations
+   *   (Optional) The expected validation violations, keyed by property path.
+   *   Each value should be an array of error messages expected for that
+   *   property.
+   * @param string|null $recipe_name
+   *   (optional) The name of the directory containing `recipe.yml`, or NULL to
+   *   randomly generate one.
+   *
+   * @dataProvider providerRecipeValidation
+   */
+  public function testRecipeValidation(string $recipe, ?array $expected_violations, ?string $recipe_name = NULL): void {
+    $dir = 'public://' . ($recipe_name ?? uniqid());
+    mkdir($dir);
+    file_put_contents($dir . '/recipe.yml', $recipe);
+
+    try {
+      Recipe::createFromDirectory($dir);
+      // If there was no error, we'd better not have been expecting any.
+      $this->assertNull($expected_violations, 'Validation errors were expected, but there were none.');
+    }
+    catch (RecipeFileException $e) {
+      $this->assertIsArray($expected_violations, 'There were validation errors, but none were expected.');
+      $this->assertIsObject($e->violations);
+
+      $actual_violations = [];
+      /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
+      foreach ($e->violations as $violation) {
+        $property_path = $violation->getPropertyPath();
+        $actual_violations[$property_path][] = (string) $violation->getMessage();
+      }
+      ksort($actual_violations);
+      ksort($expected_violations);
+      $this->assertSame($expected_violations, $actual_violations);
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7a9ea5a228734b404d475ebdf10510852e70f5f4
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RollbackTest.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Core\Datetime\Entity\DateFormat;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * @group Recipe
+ */
+class RollbackTest extends BrowserTestBase {
+
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   *
+   * Disable strict config schema because this test explicitly makes the
+   * recipe system save invalid config, to prove that it validates it after
+   * the fact and raises an error.
+   */
+  protected $strictConfigSchema = FALSE;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'system',
+    'user',
+  ];
+
+  /**
+   * @testWith ["invalid_config", "core.date_format.invalid"]
+   *           ["recipe_depend_on_invalid", "core.date_format.invalid"]
+   *           ["recipe_depend_on_invalid_config_and_valid_modules", "core.date_format.invalid"]
+   */
+  public function testRollbackForInvalidConfig(string $recipe_fixture, string $expected_invalid_config_name): void {
+    $expected_core_extension_modules = $this->config('core.extension')->get('module');
+
+    /** @var string $recipe_fixture */
+    $recipe_fixture = realpath(__DIR__ . "/../../../../fixtures/recipes/$recipe_fixture");
+    $process = $this->applyRecipe($recipe_fixture, 1);
+    $this->assertStringContainsString("There were validation errors in $expected_invalid_config_name:", $process->getErrorOutput());
+    $this->assertCheckpointsExist([
+      "Backup before the '" . Recipe::createFromDirectory($recipe_fixture)->name . "' recipe.",
+    ]);
+
+    // @see invalid_config
+    $date_formats = DateFormat::loadMultiple(['valid', 'invalid']);
+    $this->assertEmpty($date_formats, "The recipe's imported config was not rolled back.");
+
+    // @see recipe_depend_on_invalid_config_and_valid_module
+    $this->assertSame($expected_core_extension_modules, $this->config('core.extension')->get('module'));
+  }
+
+  /**
+   * Asserts that the current set of checkpoints matches the given labels.
+   *
+   * @param string[] $expected_labels
+   *   The labels of every checkpoint that is expected to exist currently, in
+   *   the expected order.
+   */
+  private function assertCheckpointsExist(array $expected_labels): void {
+    $checkpoints = \Drupal::service('config.checkpoints');
+    $labels = array_map(fn (Checkpoint $c) => $c->label, iterator_to_array($checkpoints));
+    $this->assertSame($expected_labels, array_values($labels));
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..29b1a7e784e6a35efe3ccf53ae2d7faf99a1d009
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php
@@ -0,0 +1,135 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Recipe;
+
+use Drupal\Core\Config\Action\ConfigActionException;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\entity_test\Entity\EntityTestBundle;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+
+/**
+ * Tests config actions targeting multiple entities using wildcards.
+ *
+ * @group Recipe
+ */
+class WildcardConfigActionsTest extends KernelTestBase {
+
+  use ContentTypeCreationTrait;
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'entity_test',
+    'field',
+    'node',
+    'system',
+    'text',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installConfig('node');
+
+    $this->createContentType(['type' => 'one']);
+    $this->createContentType(['type' => 'two']);
+
+    EntityTestBundle::create(['id' => 'one'])->save();
+    EntityTestBundle::create(['id' => 'two'])->save();
+
+    $field_storage = FieldStorageConfig::create([
+      'entity_type' => 'entity_test_with_bundle',
+      'field_name' => 'field_test',
+      'type' => 'boolean',
+    ]);
+    $field_storage->save();
+    FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'one'])
+      ->save();
+    FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'two'])
+      ->save();
+
+    $field_storage = FieldStorageConfig::create([
+      'entity_type' => 'node',
+      'field_name' => 'field_test',
+      'type' => 'boolean',
+    ]);
+    $field_storage->save();
+    FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'one'])
+      ->save();
+    FieldConfig::create(['field_storage' => $field_storage, 'bundle' => 'two'])
+      ->save();
+  }
+
+  /**
+   * Tests targeting multiple config entities for an action, using wildcards.
+   *
+   * @param string $expression
+   *   The expression the recipe will use to target multiple config entities.
+   * @param string[] $expected_changed_entities
+   *   The IDs of the config entities that we expect the recipe to change.
+   *
+   * @testWith ["field.field.node.one.*", ["node.one.body", "node.one.field_test"]]
+   *   ["field.field.node.*.body", ["node.one.body", "node.two.body"]]
+   *   ["field.field.*.one.field_test", ["entity_test_with_bundle.one.field_test", "node.one.field_test"]]
+   *   ["field.field.node.*.*", ["node.one.body", "node.one.field_test", "node.two.body", "node.two.field_test"]]
+   *   ["field.field.*.one.*", ["entity_test_with_bundle.one.field_test", "node.one.field_test", "node.one.body"]]
+   *   ["field.field.*.*.field_test", ["entity_test_with_bundle.one.field_test", "entity_test_with_bundle.two.field_test", "node.one.field_test", "node.two.field_test"]]
+   *   ["field.field.*.*.*", ["entity_test_with_bundle.one.field_test", "entity_test_with_bundle.two.field_test", "node.one.field_test", "node.two.field_test", "node.one.body", "node.two.body"]]
+   */
+  public function testTargetEntitiesByWildcards(string $expression, array $expected_changed_entities): void {
+    $contents = <<<YAML
+name: 'Wildcards!'
+config:
+  actions:
+    $expression:
+      setLabel: 'Changed by config action'
+YAML;
+
+    $recipe = $this->createRecipe($contents);
+    RecipeRunner::processRecipe($recipe);
+
+    $changed = $this->container->get(EntityTypeManagerInterface::class)
+      ->getStorage('field_config')
+      ->getQuery()
+      ->condition('label', 'Changed by config action')
+      ->execute();
+    sort($expected_changed_entities);
+    sort($changed);
+    $this->assertSame($expected_changed_entities, array_values($changed));
+  }
+
+  /**
+   * Tests that an invalid wildcard expression will raise an error.
+   *
+   * @testWith ["field.*.node.one.*", "No installed config entity type uses the prefix in the expression 'field.*.node.one.*'. Either there is a typo in the expression or this recipe should install an additional module or depend on another recipe."]
+   *   ["field.field.node.*.body/", " could not be parsed."]
+   */
+  public function testInvalidExpression(string $expression, string $expected_exception_message): void {
+    $contents = <<<YAML
+name: 'Wildcards gone wild...'
+config:
+  actions:
+    $expression:
+      simple_config_update:
+        label: 'Changed by config action'
+YAML;
+    $recipe = $this->createRecipe($contents);
+
+    $this->expectException(ConfigActionException::class);
+    $this->expectExceptionMessage($expected_exception_message);
+    RecipeRunner::processRecipe($recipe);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
index 735624438ae7cdea53c6cc2c2f66210c077faa6a..535b1d8c3168dc7dd4f0a6664c84150000fefac2 100644
--- a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
+++ b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
@@ -235,7 +235,7 @@ public function testQuickStartCommandProfileValidation() {
     ];
     $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
     $process->run();
-    $this->assertStringContainsString('\'umami\' is not a valid install profile. Did you mean \'demo_umami\'?', $process->getErrorOutput());
+    $this->assertMatchesRegularExpression("/'umami' is not a valid install profile or recipe\. Did you mean \W*'demo_umami'?/", $process->getErrorOutput());
   }
 
   /**
diff --git a/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php b/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e61f3c16e48feb92d24604588d7196336876c9de
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Config/Action/ConfigActionAttributeTest.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Config\Action;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Config\Action\Attribute\ConfigAction
+ * @group Config
+ */
+class ConfigActionAttributeTest extends UnitTestCase {
+
+  /**
+   * @covers ::__construct
+   */
+  public function testNoLabelNoDeriver(): void {
+    $this->expectException(InvalidPluginDefinitionException::class);
+    $this->expectExceptionMessage("The 'test' config action plugin must have either an admin label or a deriver");
+    new ConfigAction('test');
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php b/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1e1064dcdf2937e1374fcc2b7669b4b222070492
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Config/Checkpoint/CheckpointStorageTest.php
@@ -0,0 +1,296 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Config\Checkpoint;
+
+use Drupal\Component\Datetime\Time;
+use Drupal\Core\Cache\NullBackend;
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Core\Config\Checkpoint\LinearHistory;
+use Drupal\Core\Config\Checkpoint\CheckpointStorage;
+use Drupal\Core\Config\Config;
+use Drupal\Core\Config\ConfigCrudEvent;
+use Drupal\Core\Config\MemoryStorage;
+use Drupal\Core\Config\StorageCopyTrait;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\KeyValueStore\KeyValueMemoryFactory;
+use Drupal\Core\Lock\NullLockBackend;
+use Drupal\Core\State\State;
+use Drupal\Tests\UnitTestCase;
+use Drupal\TestTools\Random;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Config\Checkpoint\CheckpointStorage
+ * @group Config
+ */
+class CheckpointStorageTest extends UnitTestCase {
+
+  use StorageCopyTrait;
+
+  /**
+   * The memory storage containing the data.
+   *
+   * @var \Drupal\Core\Config\MemoryStorage
+   */
+  protected MemoryStorage $memory;
+
+  /**
+   * The checkpoint storage under test.
+   *
+   * @var \Drupal\Core\Config\Checkpoint\CheckpointStorage
+   */
+  protected CheckpointStorage $storage;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Set up a memory storage we can manipulate to set fixtures.
+    $this->memory = new MemoryStorage();
+    $keyValueMemoryFactory = new KeyValueMemoryFactory();
+    $state = new State($keyValueMemoryFactory, new NullBackend('test'), new NullLockBackend());
+    $time = new Time();
+    $checkpoints = new LinearHistory($state, $time);
+    $this->storage = new CheckpointStorage($this->memory, $checkpoints, $keyValueMemoryFactory);
+  }
+
+  /**
+   * @covers ::checkpoint
+   * @covers \Drupal\Core\Config\Checkpoint\Checkpoint
+   */
+  public function testCheckpointCreation(): void {
+    $checkpoint = $this->storage->checkpoint('Test');
+    $this->assertInstanceOf(Checkpoint::class, $checkpoint);
+    $this->assertSame('Test', $checkpoint->label);
+
+    $checkpoint2 = $this->storage->checkpoint('This will not make a checkpoint because nothing has changed');
+    $this->assertSame($checkpoint2, $checkpoint);
+    $config = $this->prophesize(Config::class);
+    $config->getName()->willReturn('test.config');
+    $config->getOriginal('', FALSE)->willReturn([]);
+    $config->getRawData()->willReturn(['foo' => 'bar']);
+    $config->getStorage()->willReturn($this->storage);
+    $event = new ConfigCrudEvent($config->reveal());
+    $this->storage->onConfigSaveAndDelete($event);
+
+    $checkpoint3 = $this->storage->checkpoint('Created test.config');
+    $this->assertNotSame($checkpoint3, $checkpoint);
+    $this->assertSame('Created test.config', $checkpoint3->label);
+
+    $checkpoint4 = $this->storage->checkpoint('This will not create a checkpoint either');
+    $this->assertSame($checkpoint4, $checkpoint3);
+
+    // Simulate a save with no change.
+    $config = $this->prophesize(Config::class);
+    $config->getName()->willReturn('test.config');
+    $config->getOriginal('', FALSE)->willReturn(['foo' => 'bar']);
+    $config->getRawData()->willReturn(['foo' => 'bar']);
+    $config->getStorage()->willReturn($this->storage);
+    $event = new ConfigCrudEvent($config->reveal());
+    $this->storage->onConfigSaveAndDelete($event);
+
+    $checkpoint5 = $this->storage->checkpoint('Save with no change');
+    $this->assertSame($checkpoint5, $checkpoint3);
+
+    // Create collection and ensure that checkpoints are kept in sync.
+    $collection = $this->storage->createCollection('test');
+    $config = $this->prophesize(Config::class);
+    $config->getName()->willReturn('test.config');
+    $config->getOriginal('', FALSE)->willReturn(['foo' => 'bar']);
+    $config->getRawData()->willReturn(['foo' => 'collection_bar']);
+    $config->getStorage()->willReturn($collection);
+    $event = new ConfigCrudEvent($config->reveal());
+    $collection->onConfigSaveAndDelete($event);
+
+    $checkpoint6 = $this->storage->checkpoint('Save in collection');
+    $this->assertNotSame($checkpoint6, $checkpoint3);
+    $this->assertSame($collection->checkpoint('Calling checkpoint on collection'), $checkpoint6);
+  }
+
+  /**
+   * @covers ::exists
+   * @covers ::read
+   * @covers ::readMultiple
+   * @covers ::listAll
+   *
+   * @dataProvider readMethodsProvider
+   */
+  public function testReadOperations(string $method, array $arguments, array $fixture): void {
+    // Create a checkpoint so the checkpoint storage can be read from.
+    $this->storage->checkpoint('');
+    $this->setRandomFixtureConfig($fixture);
+
+    $expected = call_user_func_array([$this->memory, $method], $arguments);
+    $actual = call_user_func_array([$this->storage, $method], $arguments);
+    $this->assertEquals($expected, $actual);
+  }
+
+  /**
+   * Provide the methods that work transparently.
+   *
+   * @return array
+   *   The data.
+   */
+  public static function readMethodsProvider(): array {
+    $fixture = [
+      StorageInterface::DEFAULT_COLLECTION => ['config.a', 'config.b', 'other.a'],
+    ];
+
+    $data = [];
+    $data[] = ['exists', ['config.a'], $fixture];
+    $data[] = ['exists', ['not.existing'], $fixture];
+    $data[] = ['read', ['config.a'], $fixture];
+    $data[] = ['read', ['not.existing'], $fixture];
+    $data[] = ['readMultiple', [['config.a', 'config.b', 'not']], $fixture];
+    $data[] = ['listAll', [''], $fixture];
+    $data[] = ['listAll', ['config'], $fixture];
+    $data[] = ['listAll', ['none'], $fixture];
+
+    return $data;
+  }
+
+  /**
+   * @covers ::write
+   * @covers ::delete
+   * @covers ::rename
+   * @covers ::deleteAll
+   *
+   * @dataProvider writeMethodsProvider
+   */
+  public function testWriteOperations(string $method, array $arguments, array $fixture): void {
+    $this->setRandomFixtureConfig($fixture);
+
+    // Create an independent memory storage as a backup.
+    $backup = new MemoryStorage();
+    static::replaceStorageContents($this->memory, $backup);
+
+    try {
+      call_user_func_array([$this->storage, $method], $arguments);
+      $this->fail("exception not thrown");
+    }
+    catch (\BadMethodCallException $exception) {
+      $this->assertEquals(CheckpointStorage::class . '::' . $method . ' is not allowed on a CheckpointStorage', $exception->getMessage());
+    }
+
+    // Assert that the memory storage has not been altered.
+    $this->assertEquals($backup, $this->memory);
+  }
+
+  /**
+   * Provide the methods that throw an exception.
+   *
+   * @return array
+   *   The data
+   */
+  public static function writeMethodsProvider(): array {
+    $fixture = [
+      StorageInterface::DEFAULT_COLLECTION => ['config.a', 'config.b'],
+    ];
+
+    $data = [];
+    $data[] = ['write', ['config.a', (array) Random::getGenerator()->object()], $fixture];
+    $data[] = ['write', [Random::MachineName(), (array) Random::getGenerator()->object()], $fixture];
+    $data[] = ['delete', ['config.a'], $fixture];
+    $data[] = ['delete', [Random::MachineName()], $fixture];
+    $data[] = ['rename', ['config.a', 'config.b'], $fixture];
+    $data[] = ['rename', ['config.a', Random::MachineName()], $fixture];
+    $data[] = ['rename', [Random::MachineName(), Random::MachineName()], $fixture];
+    $data[] = ['deleteAll', [''], $fixture];
+    $data[] = ['deleteAll', ['config'], $fixture];
+    $data[] = ['deleteAll', ['other'], $fixture];
+
+    return $data;
+  }
+
+  /**
+   * @covers ::getAllCollectionNames
+   * @covers ::getCollectionName
+   * @covers ::createCollection
+   */
+  public function testCollections(): void {
+    $ref_readFromCheckpoint = new \ReflectionProperty($this->storage, 'readFromCheckpoint');
+
+    // Create some checkpoints so the checkpoint storage can be read from.
+    $checkpoint1 = $this->storage->checkpoint('1');
+    $config = $this->prophesize(Config::class);
+    $config->getName()->willReturn('test.config');
+    $config->getOriginal('', FALSE)->willReturn([]);
+    $config->getRawData()->willReturn(['foo' => 'bar']);
+    $config->getStorage()->willReturn($this->storage);
+    $event = new ConfigCrudEvent($config->reveal());
+    $this->storage->onConfigSaveAndDelete($event);
+    $checkpoint2 = $this->storage->checkpoint('2');
+
+    $fixture = [
+      StorageInterface::DEFAULT_COLLECTION => [$this->randomMachineName()],
+      'A' => [$this->randomMachineName()],
+      'B' => [$this->randomMachineName()],
+      'C' => [$this->randomMachineName()],
+    ];
+    $this->setRandomFixtureConfig($fixture);
+
+    $this->assertEquals(['A', 'B', 'C'], $this->storage->getAllCollectionNames());
+    foreach (array_keys($fixture) as $collection) {
+      $storage = $this->storage->createCollection($collection);
+      // Assert that the collection storage is still a checkpoint storage.
+      $this->assertInstanceOf(CheckpointStorage::class, $storage);
+      $this->assertEquals($collection, $storage->getCollectionName());
+
+      // Ensure that the
+      // \Drupal\Core\Config\Checkpoint\CheckpointStorage::$readFromCheckpoint
+      // property is kept in sync.
+      $this->storage->setCheckpointToReadFrom($checkpoint2);
+      $this->assertSame($checkpoint2->id, $ref_readFromCheckpoint->getValue($storage->createCollection($collection))?->id);
+      if (isset($previous_collection)) {
+        $previous_collection->setCheckpointToReadFrom($checkpoint1);
+        $this->assertSame($checkpoint1->id, $ref_readFromCheckpoint->getValue($storage->createCollection($collection))?->id);
+        $this->assertSame($checkpoint1->id, $ref_readFromCheckpoint->getValue($this->storage->createCollection($collection))?->id);
+      }
+
+      // Save the storage in a variable so we can test use
+      // setCheckpointToReadFrom() on it.
+      $previous_collection = $storage;
+    }
+  }
+
+  /**
+   * @covers ::encode
+   * @covers ::decode
+   */
+  public function testEncodeDecode(): void {
+    $array = (array) $this->getRandomGenerator()->object();
+    $string = $this->getRandomGenerator()->string();
+
+    // Assert reversibility of encoding and decoding.
+    $this->assertEquals($array, $this->storage->decode($this->storage->encode($array)));
+    $this->assertEquals($string, $this->storage->encode($this->storage->decode($string)));
+    // Assert same results as the decorated storage.
+    $this->assertEquals($this->memory->encode($array), $this->storage->encode($array));
+    $this->assertEquals($this->memory->decode($string), $this->storage->decode($string));
+  }
+
+  /**
+   * Generate random config in the memory storage.
+   *
+   * @param array $config
+   *   The config keys, keyed by the collection.
+   */
+  protected function setRandomFixtureConfig(array $config): void {
+    // Erase previous fixture.
+    foreach (array_merge([StorageInterface::DEFAULT_COLLECTION], $this->memory->getAllCollectionNames()) as $collection) {
+      $this->memory->createCollection($collection)->deleteAll();
+    }
+
+    foreach ($config as $collection => $keys) {
+      $storage = $this->memory->createCollection($collection);
+      foreach ($keys as $key) {
+        // Create some random config.
+        $storage->write($key, (array) $this->getRandomGenerator()->object());
+      }
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php b/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1a3ee6cd7ccef98bcae46c8768f9817498f7b1ea
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Config/Checkpoint/LinearHistoryTest.php
@@ -0,0 +1,190 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Config\Checkpoint;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Config\Checkpoint\Checkpoint;
+use Drupal\Core\Config\Checkpoint\CheckpointExistsException;
+use Drupal\Core\Config\Checkpoint\UnknownCheckpointException;
+use Drupal\Core\Config\Checkpoint\LinearHistory;
+use Drupal\Core\State\StateInterface;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Config\Checkpoint\LinearHistory
+ * @group Config
+ */
+class LinearHistoryTest extends UnitTestCase {
+
+  /**
+   * The key used store of all the checkpoint names in state.
+   *
+   * @see \Drupal\Core\Config\Checkpoint\Checkpoints::CHECKPOINT_KEY
+   */
+  private const CHECKPOINT_KEY = 'config.checkpoints';
+
+  /**
+   * @covers ::add
+   * @covers ::count
+   * @covers ::getActiveCheckpoint
+   * @covers \Drupal\Core\Config\Checkpoint\Checkpoint
+   */
+  public function testAdd(): void {
+    $state = $this->prophesize(StateInterface::class);
+    $state->get(self::CHECKPOINT_KEY, [])->willReturn([]);
+    $state->set(self::CHECKPOINT_KEY, Argument::any())->willReturn(NULL);
+    $time = $this->prophesize(TimeInterface::class);
+    $time->getCurrentTime()->willReturn(1701539520, 1701539994);
+    $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+    $this->assertCount(0, $checkpoints);
+    $this->assertNull($checkpoints->getActiveCheckpoint());
+
+    $checkpoint = $checkpoints->add('hash1', 'Label');
+
+    $this->assertSame('hash1', $checkpoint->id);
+    $this->assertSame('Label', $checkpoint->label);
+    $this->assertNull($checkpoint->parent);
+    $this->assertSame(1701539520, $checkpoint->timestamp);
+
+    $this->assertCount(1, $checkpoints);
+    $this->assertSame('hash1', $checkpoints->getActiveCheckpoint()?->id);
+
+    // Test that on the second call to add the ancestor is set correctly.
+    $checkpoint2 = $checkpoints->add('hash2', new FormattableMarkup('Another label', []));
+    $this->assertSame('hash2', $checkpoint2->id);
+    $this->assertSame('Another label', (string) $checkpoint2->label);
+    $this->assertSame($checkpoint->id, $checkpoint2->parent);
+    $this->assertSame(1701539994, $checkpoint2->timestamp);
+
+    $this->assertCount(2, $checkpoints);
+    $this->assertSame('hash2', $checkpoints->getActiveCheckpoint()?->id);
+
+    // Test that the checkpoints object can be iterated over.
+    $i = 0;
+    foreach ($checkpoints as $value) {
+      $i++;
+      $this->assertInstanceOf(Checkpoint::class, $value);
+      $this->assertSame('hash' . $i, $value->id);
+    }
+  }
+
+  /**
+   * @covers ::add
+   */
+  public function testAddException(): void {
+    $state = $this->prophesize(StateInterface::class);
+    $state->get(self::CHECKPOINT_KEY, [])->willReturn([]);
+    $state->set(self::CHECKPOINT_KEY, Argument::any())->willReturn(NULL);
+    $time = $this->prophesize(TimeInterface::class);
+    $time->getCurrentTime()->willReturn(1701539520, 1701539994);
+    $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+    $checkpoints->add('hash1', 'Label');
+    // Add another checkpoint with the same ID and an exception should be
+    // triggered.
+    $this->expectException(CheckpointExistsException::class);
+    $this->expectExceptionMessage('Cannot create a checkpoint with the ID "hash1" as it already exists');
+    $checkpoints->add('hash1', 'Label');
+  }
+
+  /**
+   * @covers ::delete
+   */
+  public function testDeleteAll(): void {
+    $state = $this->prophesize(StateInterface::class);
+    $state->get(self::CHECKPOINT_KEY, [])->willReturn([
+      'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL),
+      'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'),
+      'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'),
+    ]);
+    $state->delete(self::CHECKPOINT_KEY)->willReturn();
+    $time = $this->prophesize(TimeInterface::class);
+    $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+    $this->assertCount(3, $checkpoints);
+    $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id);
+    $checkpoints->deleteAll();
+    $this->assertCount(0, $checkpoints);
+    $this->assertNull($checkpoints->getActiveCheckpoint());
+  }
+
+  /**
+   * @covers ::delete
+   */
+  public function testDelete(): void {
+    $state = $this->prophesize(StateInterface::class);
+    $test_data = [
+      'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL),
+      'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'),
+      'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'),
+    ];
+    $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data);
+    unset($test_data['hash1'], $test_data['hash2']);
+    $state->set(self::CHECKPOINT_KEY, $test_data)->willReturn();
+    $time = $this->prophesize(TimeInterface::class);
+    $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+    $this->assertCount(3, $checkpoints);
+    $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id);
+    $checkpoints->delete('hash2');
+    $this->assertCount(1, $checkpoints);
+    $this->assertSame('hash3', $checkpoints->getActiveCheckpoint()?->id);
+  }
+
+  /**
+   * @covers ::delete
+   */
+  public function testDeleteException(): void {
+    $state = $this->prophesize(StateInterface::class);
+    $state->get(self::CHECKPOINT_KEY, [])->willReturn([]);
+    $time = $this->prophesize(TimeInterface::class);
+    $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+    $this->expectException(UnknownCheckpointException::class);
+    $this->expectExceptionMessage('Cannot delete a checkpoint with the ID "foo" as it does not exist');
+
+    $checkpoints->delete('foo');
+  }
+
+  /**
+   * @covers ::getParents
+   */
+  public function testGetParents(): void {
+    $state = $this->prophesize(StateInterface::class);
+    $test_data = [
+      'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL),
+      'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'),
+      'hash3' => new Checkpoint('hash3', 'Three', 1701539530, 'hash2'),
+    ];
+    $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data);
+    $time = $this->prophesize(TimeInterface::class);
+    $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+    $this->assertSame(['hash2' => $test_data['hash2'], 'hash1' => $test_data['hash1']], iterator_to_array($checkpoints->getParents('hash3')));
+    $this->assertSame(['hash1' => $test_data['hash1']], iterator_to_array($checkpoints->getParents('hash2')));
+    $this->assertSame([], iterator_to_array($checkpoints->getParents('hash1')));
+  }
+
+  /**
+   * @covers ::getParents
+   */
+  public function testGetParentsException(): void {
+    $state = $this->prophesize(StateInterface::class);
+    $test_data = [
+      'hash1' => new Checkpoint('hash1', 'One', 1701539510, NULL),
+      'hash2' => new Checkpoint('hash2', 'Two', 1701539520, 'hash1'),
+    ];
+    $state->get(self::CHECKPOINT_KEY, [])->willReturn($test_data);
+    $time = $this->prophesize(TimeInterface::class);
+    $checkpoints = new LinearHistory($state->reveal(), $time->reveal());
+
+    $this->expectException(UnknownCheckpointException::class);
+    $this->expectExceptionMessage('The checkpoint "hash3" does not exist');
+    iterator_to_array($checkpoints->getParents('hash3'));
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php b/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6abfb5b6733164623b159f4a3c2f5cb0cefcbcc9
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/DefaultContent/FinderTest.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\DefaultContent;
+
+use Drupal\Component\FileSystem\FileSystem;
+use Drupal\Core\DefaultContent\Finder;
+use Drupal\Core\DefaultContent\ImportException;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\Core\DefaultContent\Finder
+ * @group DefaultContent
+ */
+class FinderTest extends UnitTestCase {
+
+  /**
+   * Tests that any discovered entity data is sorted into dependency order.
+   */
+  public function testFoundDataIsInDependencyOrder(): void {
+    $finder = new Finder(__DIR__ . '/../../../../fixtures/default_content');
+
+    $expected_order = [
+      // First is the author of the node.
+      '94503467-be7f-406c-9795-fc25baa22203',
+      // Next, the taxonomy term referenced by the node.
+      '550f86ad-aa11-4047-953f-636d42889f85',
+      // Then we have the node itself, since it has no other dependencies.
+      'e1714f23-70c0-4493-8e92-af1901771921',
+      // Finally, the menu link to the node.
+      '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b',
+    ];
+    $this->assertSame($expected_order, array_slice(array_keys($finder->data), 0, 4));
+  }
+
+  /**
+   * Tests that files without UUIDs will raise an exception.
+   */
+  public function testExceptionIfNoUuid(): void {
+    $dir = FileSystem::getOsTemporaryDirectory();
+    $this->assertIsString($dir);
+    /** @var string $dir */
+    file_put_contents($dir . '/no-uuid.yml', '_meta: {}');
+
+    $this->expectException(ImportException::class);
+    $this->expectExceptionMessage("$dir/no-uuid.yml does not have a UUID.");
+    new Finder($dir);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5ff124fbe0a93da3e090dfac299870648ddc28f7
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php
@@ -0,0 +1,306 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Recipe;
+
+use Drupal\Core\Config\MemoryStorage;
+use Drupal\Core\Config\NullStorage;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\Recipe\RecipeConfigStorageWrapper;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeConfigStorageWrapper
+ * @group Recipe
+ */
+class RecipeConfigStorageWrapperTest extends UnitTestCase {
+
+  /**
+   * Validate that an empty set of storage backends returns null storage.
+   */
+  public function testNullStorage(): void {
+    $this->assertInstanceOf(
+      NullStorage::class,
+      RecipeConfigStorageWrapper::createStorageFromArray([])
+    );
+  }
+
+  /**
+   * Validate that a single storage returns exactly the same instance.
+   */
+  public function testSingleStorage(): void {
+    $storages = [new NullStorage()];
+    $this->assertSame(
+      $storages[0],
+      RecipeConfigStorageWrapper::createStorageFromArray($storages)
+    );
+  }
+
+  /**
+   * Validate that multiple storages return underlying values correctly.
+   */
+  public function testMultipleStorages(): void {
+    $a = new MemoryStorage();
+    $a->write('a_key', ['a_data_first']);
+    $b = new MemoryStorage();
+
+    // Add a conflicting key so that we can test the first value is returned.
+    $b->write('a_key', ['a_data_second']);
+    $b->write('b_key', ['b_data']);
+
+    // We test with a third storage as well since only two storages can be done
+    // via the constructor alone.
+    $c = new MemoryStorage();
+    $c->write('c_key', ['c_data']);
+
+    $storages = [$a, $b, $c];
+    $wrapped = RecipeConfigStorageWrapper::createStorageFromArray($storages);
+
+    $this->assertSame($a->read('a_key'), $wrapped->read('a_key'));
+    $this->assertNotEquals($b->read('a_key'), $wrapped->read('a_key'));
+    $this->assertSame($b->read('b_key'), $wrapped->read('b_key'));
+    $this->assertSame($c->read('c_key'), $wrapped->read('c_key'));
+  }
+
+  /**
+   * Validate that the first storage checks existence first.
+   */
+  public function testLeftSideExists(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $a->expects($this->once())->method('exists')->with('a_key')
+      ->willReturn(TRUE);
+    $b = $this->createMock(StorageInterface::class);
+    $b->expects($this->never())->method('exists');
+
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertTrue($storage->exists('a_key'));
+  }
+
+  /**
+   * Validate that we fall back to the second storage.
+   */
+  public function testRightSideExists(): void {
+    [$a, $b] = $this->generateStorages(TRUE);
+
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $storage->exists('a_key');
+  }
+
+  /**
+   * Validate FALSE when neither storage contains a key.
+   */
+  public function testNotExists(): void {
+    [$a, $b] = $this->generateStorages(FALSE);
+
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertFalse($storage->exists('a_key'));
+  }
+
+  /**
+   * Validate that we read from storage A first.
+   */
+  public function testReadFromA(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $value = ['a_value'];
+    $a->expects($this->once())->method('read')->with('a_key')
+      ->willReturn($value);
+    $b = $this->createMock(StorageInterface::class);
+    $b->expects($this->never())->method('read');
+
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertSame($value, $storage->read('a_key'));
+  }
+
+  /**
+   * Validate that we read from storage B second.
+   */
+  public function testReadFromB(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $a->expects($this->once())->method('read')->with('a_key')
+      ->willReturn(FALSE);
+    $b = $this->createMock(StorageInterface::class);
+    $value = ['a_value'];
+    $b->expects($this->once())->method('read')->with('a_key')
+      ->willReturn($value);
+
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertSame($value, $storage->read('a_key'));
+  }
+
+  /**
+   * Validate when neither storage can read a value.
+   */
+  public function testReadFails(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $a->expects($this->once())->method('read')->with('a_key')
+      ->willReturn(FALSE);
+    $b = $this->createMock(StorageInterface::class);
+    $b->expects($this->once())->method('read')->with('a_key')
+      ->willReturn(FALSE);
+
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertFalse($storage->read('a_key'));
+  }
+
+  /**
+   * Test reading multiple values.
+   */
+  public function testReadMultiple(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
+      ->willReturn(['a_key' => ['a_value']]);
+    $b = $this->createMock(StorageInterface::class);
+    $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
+      ->willReturn(['b_key' => ['b_value']]);
+
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertEquals([
+      'a_key' => ['a_value'],
+      'b_key' => ['b_value'],
+    ], $storage->readMultiple(['a_key', 'b_key']));
+  }
+
+  /**
+   * Test that storage A has precedence over storage B.
+   */
+  public function testReadMultipleStorageA(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
+      ->willReturn(['a_key' => ['a_value']]);
+    $b = $this->createMock(StorageInterface::class);
+    $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
+      ->willReturn(['a_key' => ['a_conflicting_value'], 'b_key' => ['b_value']]);
+
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertEquals([
+      'a_key' => ['a_value'],
+      'b_key' => ['b_value'],
+    ], $storage->readMultiple(['a_key', 'b_key']));
+  }
+
+  /**
+   * Test methods that are unsupported.
+   *
+   * @param string $method
+   *   The method to call.
+   * @param array $args
+   *   The arguments to pass to the method.
+   *
+   * @testWith ["write", "name", []]
+   *           ["delete", "name"]
+   *           ["rename", "old_name", "new_name"]
+   *           ["deleteAll"]
+   */
+  public function testUnsupportedMethods(string $method, ...$args): void {
+    $this->expectException(\BadMethodCallException::class);
+    $storage = new RecipeConfigStorageWrapper(new NullStorage(), new NullStorage());
+    $storage->{$method}(...$args);
+  }
+
+  /**
+   * Test that we only use storage A's encode method.
+   */
+  public function testEncode(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $b = $this->createMock(StorageInterface::class);
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->expectException(\BadMethodCallException::class);
+    $storage->encode(['value']);
+  }
+
+  /**
+   * Test that we only use storage A's decode method.
+   */
+  public function testDecode(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $b = $this->createMock(StorageInterface::class);
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->expectException(\BadMethodCallException::class);
+    $storage->decode('value');
+  }
+
+  /**
+   * Test that list all merges values and makes them unique.
+   */
+  public function testListAll(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $a->method('listAll')->with('node.')
+      ->willReturn(['node.type']);
+    $b = $this->createMock(StorageInterface::class);
+    $b->method('listAll')->with('node.')
+      ->willReturn(['node.type', 'node.id']);
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertEquals([
+      0 => 'node.type',
+      2 => 'node.id',
+    ], $storage->listAll('node.'));
+  }
+
+  /**
+   * Test creating a collection passes the name through to the child storages.
+   */
+  public function testCreateCollection(): void {
+    $collection_name = 'collection';
+    $a = $this->createMock(StorageInterface::class);
+    $b = $this->createMock(StorageInterface::class);
+    /** @var \PHPUnit\Framework\MockObject\MockObject $mock */
+    foreach ([$a, $b] as $mock) {
+      $mock->expects($this->once())->method('createCollection')
+        ->with($collection_name)->willReturn(new NullStorage($collection_name));
+    }
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $new = $storage->createCollection($collection_name);
+    $this->assertInstanceOf(RecipeConfigStorageWrapper::class, $new);
+    $this->assertEquals($collection_name, $new->getCollectionName());
+    $this->assertNotEquals($storage, $new);
+  }
+
+  /**
+   * Test that we merge and return only unique collection names.
+   */
+  public function testGetAllCollectionNames(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $a->expects($this->once())->method('getAllCollectionNames')
+      ->willReturn(['collection_1', 'collection_2']);
+    $b = $this->createMock(StorageInterface::class);
+    $b->expects($this->once())->method('getAllCollectionNames')
+      ->willReturn(['collection_3', 'collection_1', 'collection_2']);
+    $storage = new RecipeConfigStorageWrapper($a, $b);
+    $this->assertEquals([
+      'collection_1',
+      'collection_2',
+      'collection_3',
+    ], $storage->getAllCollectionNames());
+  }
+
+  /**
+   * Test the collection name is stored properly.
+   */
+  public function testGetCollection(): void {
+    $a = $this->createMock(StorageInterface::class);
+    $b = $this->createMock(StorageInterface::class);
+    $storage = new RecipeConfigStorageWrapper($a, $b, 'collection');
+    $this->assertEquals('collection', $storage->getCollectionName());
+  }
+
+  /**
+   * Generate two storages where the second storage should return a value.
+   *
+   * @param bool $b_return
+   *   The return value for storage $b's exist method.
+   *
+   * @return \Drupal\Core\Config\StorageInterface[]
+   *   An array of two mocked storages.
+   */
+  private function generateStorages(bool $b_return): array {
+    $a = $this->createMock(StorageInterface::class);
+    $a->expects($this->once())->method('exists')->with('a_key')
+      ->willReturn(FALSE);
+    $b = $this->createMock(StorageInterface::class);
+    $b->expects($this->once())->method('exists')->with('a_key')
+      ->willReturn($b_return);
+    return [$a, $b];
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php b/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..35505fe1fe5b294c9bf1b8f617644ebabffd674c
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Recipe/RecipeQuickStartTest.php
@@ -0,0 +1,181 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Recipe;
+
+use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
+use Drupal\Core\Test\TestDatabase;
+use Drupal\Tests\BrowserTestBase;
+use GuzzleHttp\Client;
+use GuzzleHttp\Cookie\CookieJar;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Process\PhpExecutableFinder;
+use Symfony\Component\Process\Process;
+
+/**
+ * Tests the quick-start command with recipes.
+ *
+ * These tests are run in a separate process because they load Drupal code via
+ * an include.
+ *
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ * @requires extension pdo_sqlite
+ *
+ * @group Command
+ * @group Recipe
+ */
+class RecipeQuickStartTest extends TestCase {
+
+  /**
+   * The PHP executable path.
+   *
+   * @var string
+   */
+  protected string $php;
+
+  /**
+   * A test database object.
+   *
+   * @var \Drupal\Core\Test\TestDatabase
+   */
+  protected TestDatabase $testDb;
+
+  /**
+   * The Drupal root directory.
+   *
+   * @var string
+   */
+  protected string $root;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $php_executable_finder = new PhpExecutableFinder();
+    $this->php = (string) $php_executable_finder->find();
+    $this->root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2);
+    if (!is_writable("{$this->root}/sites/simpletest")) {
+      $this->markTestSkipped('This test requires a writable sites/simpletest directory');
+    }
+    // Get a lock and a valid site path.
+    $this->testDb = new TestDatabase();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    if ($this->testDb) {
+      $test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath();
+      if (file_exists($test_site_directory)) {
+        // @todo use the tear down command from
+        //   https://www.drupal.org/project/drupal/issues/2926633
+        // Delete test site directory.
+        $this->fileUnmanagedDeleteRecursive($test_site_directory, BrowserTestBase::filePreDeleteCallback(...));
+      }
+    }
+    parent::tearDown();
+  }
+
+  /**
+   * Tests the quick-start command with a recipe.
+   */
+  public function testQuickStartRecipeCommand(): void {
+    $sqlite = (string) (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
+    if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
+      $this->markTestSkipped();
+    }
+
+    // Install a site using the standard recipe to ensure the one time login
+    // link generation works.
+
+    $install_command = [
+      $this->php,
+      'core/scripts/drupal',
+      'quick-start',
+      'core/recipes/standard',
+      "--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
+      '--suppress-login',
+    ];
+    $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
+    $process->setTimeout(500);
+    $process->start();
+    $guzzle = new Client();
+    $port = FALSE;
+    $process->waitUntil(function ($type, $output) use (&$port) {
+      if (preg_match('/127.0.0.1:(\d+)/', $output, $match)) {
+        $port = $match[1];
+        return TRUE;
+      }
+    });
+    // The progress bar uses STDERR to write messages.
+    $this->assertStringContainsString('Congratulations, you installed Drupal!', $process->getErrorOutput());
+    // Ensure the command does not trigger any PHP deprecations.
+    $this->assertStringNotContainsStringIgnoringCase('deprecated', $process->getErrorOutput());
+    $this->assertNotFalse($port, "Web server running on port $port");
+
+    // Give the server a couple of seconds to be ready.
+    sleep(2);
+    $this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $process->getOutput());
+
+    // Generate a cookie so we can make a request against the installed site.
+    define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
+    chmod($this->testDb->getTestSitePath(), 0755);
+    $cookieJar = CookieJar::fromArray([
+      'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
+    ], '127.0.0.1');
+
+    $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
+    $content = (string) $response->getBody();
+    $this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
+    // Test content from Standard front page.
+    $this->assertStringContainsString('Congratulations and welcome to the Drupal community.', $content);
+
+    // Stop the web server.
+    $process->stop();
+  }
+
+  /**
+   * Deletes all files and directories in the specified path recursively.
+   *
+   * Note this method has no dependencies on Drupal core to ensure that the
+   * test site can be torn down even if something in the test site is broken.
+   *
+   * @param string $path
+   *   A string containing either a URI or a file or directory path.
+   * @param callable $callback
+   *   (optional) Callback function to run on each file prior to deleting it and
+   *   on each directory prior to traversing it. For example, can be used to
+   *   modify permissions.
+   *
+   * @return bool
+   *   TRUE for success or if path does not exist, FALSE in the event of an
+   *   error.
+   *
+   * @see \Drupal\Core\File\FileSystemInterface::deleteRecursive()
+   */
+  protected function fileUnmanagedDeleteRecursive($path, $callback = NULL): bool {
+    if (isset($callback)) {
+      call_user_func($callback, $path);
+    }
+    if (is_dir($path)) {
+      $dir = dir($path);
+      assert($dir instanceof \Directory);
+      while (($entry = $dir->read()) !== FALSE) {
+        if ($entry == '.' || $entry == '..') {
+          continue;
+        }
+        $entry_path = $path . '/' . $entry;
+        $this->fileUnmanagedDeleteRecursive($entry_path, $callback);
+      }
+      $dir->close();
+
+      return rmdir($path);
+    }
+    return unlink($path);
+  }
+
+}
diff --git a/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml b/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f126741702a33b94e93b48bbc43a76f631178006
--- /dev/null
+++ b/core/tests/fixtures/default_content/a6b79928-838f-44bd-a8f0-44c2fff9e4cc.yml
@@ -0,0 +1,27 @@
+_meta:
+  version: '1.0'
+  entity_type: file
+  uuid: a6b79928-838f-44bd-a8f0-44c2fff9e4cc
+  default_langcode: en
+default:
+  uid:
+    -
+      target_id: 1
+  filename:
+    -
+      value: druplicon-different.png
+  uri:
+    -
+      value: 'public://2024-03/druplicon.png'
+  filemime:
+    -
+      value: text/plain
+  filesize:
+    -
+      value: 11
+  status:
+    -
+      value: true
+  created:
+    -
+      value: 1711121742
diff --git a/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml b/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml
new file mode 100644
index 0000000000000000000000000000000000000000..df66730670da76403c02e5f99fe5774ed0181ce0
--- /dev/null
+++ b/core/tests/fixtures/default_content/block_content/d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf.yml
@@ -0,0 +1,24 @@
+_meta:
+  version: '1.0'
+  entity_type: block_content
+  uuid: d9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf
+  bundle: basic
+  default_langcode: en
+default:
+  status:
+    -
+      value: true
+  info:
+    -
+      value: 'Useful Info'
+  reusable:
+    -
+      value: true
+  revision_translation_affected:
+    -
+      value: true
+  body:
+    -
+      value: "I'd love to put some useful info here."
+      format: plain_text
+      summary: ''
diff --git a/core/tests/fixtures/default_content/druplicon.png b/core/tests/fixtures/default_content/druplicon.png
new file mode 100644
index 0000000000000000000000000000000000000000..51bb1074d33efe7eb561a572ca148b105657fd81
--- /dev/null
+++ b/core/tests/fixtures/default_content/druplicon.png
@@ -0,0 +1 @@
+Not a PNG.
diff --git a/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml b/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f64d6f0eff580399f88bc7335df07d9b9372fb23
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/23a7f61f-1db3-407d-a6dd-eb4731995c9f.yml
@@ -0,0 +1,27 @@
+_meta:
+  version: '1.0'
+  entity_type: file
+  uuid: 23a7f61f-1db3-407d-a6dd-eb4731995c9f
+  default_langcode: en
+default:
+  uid:
+    -
+      target_id: 1
+  filename:
+    -
+      value: druplicon-duplicate.png
+  uri:
+    -
+      value: 'public://2024-03/druplicon.png'
+  filemime:
+    -
+      value: image/png
+  filesize:
+    -
+      value: 3905
+  status:
+    -
+      value: true
+  created:
+    -
+      value: 1711121742
diff --git a/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml b/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d07b0e634661a027deeb3890862f481f583aea82
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8.yml
@@ -0,0 +1,27 @@
+_meta:
+  version: '1.0'
+  entity_type: file
+  uuid: 2b8e0616-3ef0-4a91-8cfb-b31d9128f9f8
+  default_langcode: en
+default:
+  uid:
+    -
+      target_id: 1
+  filename:
+    -
+      value: dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png
+  uri:
+    -
+      value: 'public://2024-03/dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png'
+  filemime:
+    -
+      value: image/png
+  filesize:
+    -
+      value: 1233169
+  status:
+    -
+      value: true
+  created:
+    -
+      value: 1711729897
diff --git a/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml b/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml
new file mode 100644
index 0000000000000000000000000000000000000000..863174926295eaf037ef2398d70def7f1054ae03
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d.yml
@@ -0,0 +1,27 @@
+_meta:
+  version: '1.0'
+  entity_type: file
+  uuid: 7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d
+  default_langcode: en
+default:
+  uid:
+    -
+      target_id: 1
+  filename:
+    -
+      value: druplicon_copy.png
+  uri:
+    -
+      value: 'public://druplicon_copy.png'
+  filemime:
+    -
+      value: image/png
+  filesize:
+    -
+      value: 3905
+  status:
+    -
+      value: true
+  created:
+    -
+      value: 1711121742
diff --git a/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml b/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7b2a0aa83a6d81d90a4b84e5ab3788c2eb6b80eb
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/d8404562-efcc-40e3-869e-40132d53fe0b.yml
@@ -0,0 +1,27 @@
+_meta:
+  version: '1.0'
+  entity_type: file
+  uuid: d8404562-efcc-40e3-869e-40132d53fe0b
+  default_langcode: en
+default:
+  uid:
+    -
+      target_id: 1
+  filename:
+    -
+      value: druplicon.png
+  uri:
+    -
+      value: 'public://2024-03/druplicon.png'
+  filemime:
+    -
+      value: image/png
+  filesize:
+    -
+      value: 3905
+  status:
+    -
+      value: true
+  created:
+    -
+      value: 1711121742
diff --git a/core/tests/fixtures/default_content/file/druplicon.png b/core/tests/fixtures/default_content/file/druplicon.png
new file mode 100644
index 0000000000000000000000000000000000000000..3b49a4ce78dc8b1ce754706f400b3b61a99857d1
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/druplicon.png
@@ -0,0 +1,18 @@
+‰PNG
+
+���
IHDR���X���d���-÷â��IDATxÚí]	PTW}€‰FͦÉĤ’Te2©ÔÄL*“Ô$•É:¥©LM1jÔ¸ÄwA6Ã*;"›H@\AEAQDÅEÁ]dß¡YîÜ÷œ/{ó»û74ðOÕ-–æÿþÿô}çÝ{ß}¢ò0tË$¦>ïʁºŽMѵo!¦Û'ÃÔwò"à••û€è;WÃÄ{•]Ôuøe3? ³Ý²ˆ1ÛýÒñôbM)²æ#4ÏSD„@˜õGkQM3$åV?!Ø<ˆc/o"BA˜øÌúÊzPd<ªg3[}ˆ®So"BºFÞ(�ŠÌ²FÐXßIòÊh Ú6€½žˆor{wÙhïè�ŠÛèÁc7œ¦äv'Yמ’¼ŽˆIƍ7rnK-¬Çn—QR{Ûªã€:M59„ˆàÓíS×µo=ŒÒÀ¡º©–GeöI0§É('OB¸9¾DD¿ž«ý‚‰s{ìGÀ¡¥­ö¥–Â.—(™RÅɳ¶¶¢dXݼvz`Ö'ëC »¼8T5¶Bȵ0O—Fl/]f±²¾S
Ö/¾'£&Þ+'9µyÇgà„
’vHÌ®ßKEðkÄP[KÉ“Ñ–¢çHcæÇHôdÔaÎöÐk³°Ûy•À¡ º‰-Ÿ‹OlšÿuJ˜ü¶ä %™zt-JÐ"2*`ìí2ÉØþLÊ‚¾p«¤þ)Á®çò)QŠÛÊ@‚i‚ÒŒǶ‘*Æ97¯¿�eõ-ИDpÛÄçR‚„³Ç�%ˆ–ujuìH‰Œ1tnX…ÕM0nÕ=%ø‡ÀB‘Û;~6ݬÎlâm>\£ƒ7ˆá¶Bm×(¸÷¨x�=[~—‹¹Ž	ṍ,{Sž­8Jãg ×‰×ûÚ0™Àvh#÷›BNy=ð‹ywßxÄÈõN*„÷œ.RÇ–F�ѱbì¹WÕuÖd¬¡“dëÑ AÂøB‚1Ú¡ôÇO¥áçà›ƒG.g«OÒ”›®œTÓŒRÕä`<µ»Ÿo
+…ŒâZ8©Hë$×do¨Ñ*[´ÈL+ZD²Qr?TÓshqŒ¹	Xü’	µÍmœ,0[qÔ×Æ™ÒëúNT2b‡:B˜÷¼‰KG4.ëÈŠŠ	K…9rWË‚1\ÍWÌ<Ž.OÑBRÞ§ú$^Aï,ñ…Û%µ +Jj[ (¹ø)¹‹"ï³ëYÄ z6ןzs5JƸA$×3âk«=¬ #+r*šX#×`w:¨³zƒ
+Û¯¡tò«E’Ç¹a³=£iqF&4µ¶C\fÅSb½Ñ¦°DbxØü]”äJLõÕ•)þ¦ÛO ¹²±û ¼‚¯vJ‚WR|âqeøË™“‹"eŸî†^Ç¡M×mÂ,¶Ók™m8þ�^³97ìÈåŒM|ÚÖ»Å|´¾·¤Ã\n¯õF›šÆE
+ÃØâØŠ6®ž,ª?ìå·û¶×4óLz{­CÂCnÙgD›ô4-š‘dÅ»>ÇÏñ,={¿B+JÇõÂZ¿ºk­^Ø-˜°é̈!—«] S’‹ÈëÔ™ÔÂç'A³i šœV\‡rPÒÍk—¾STOk…+à¹?‘Š™["åÕÝÞúÍ—•û�#ýJ~
ø_)êFì–ؘºÉÁÈ6cÏ'¥Î›Ú‰Ží7DfèÚ7/‰Hg¤¾_	¥u-]Ñ)µq‹gº™KbüÇï:×â4òm®ß/6p¦^üXVip;?<.Pò¤ÓYCÌÆ&nf:;zÌŒÌ=˜FË	oè9JÞ´=+•XO$ξxÝ–éìè³9¾ÁL*ЋËøzo�®JÀ³Ølçv>¿±6§ráû€\d0zÍÔ‡’ÛÝ‹µmÖòizn#«bØI^´L€¯}¯ÁŒàTøÔ3&m9K/7Éu%x¶+Nx›«¥†mxP8Ö>Eòx,ÿ#©½MËšJÅÒ/tìÚñ`‘Àì·ý}làB½¸¢¿bÎz,dˆäñ,]"¡}ÛLKQ|GzÙ/&ˉäñYJ2ñêŸ`}G*ñ=.Ÿ!Ú¶£ž<>†NˆDJ5Ìî{6ä9a,'’ÇK‚&˜É„Ýô®;zî“!"y¼Š<ŒL#Š½]c_‰=ä½\}BÓ"Óß—0<Éãûò·Ÿ7¶±¤Ã3Ô`‘Àl^,s:üAM9‰K "ÒlY¤lär:¬m³…ÐÖ ÌLDû³U1\Ü+“±M8ZV‡	~SG–‰ì;©à
+ë²+Ä[¦ÓúC†"™}Ù‚J–|f¸Ö%Ê	Ûb'’ÙÓï‘›\®Ï2:1E–R-SАàšdˆ„vµ¥…!
%¢•Nr"©œÑÉÞÈCH‚Û‘`§¡¸Öl–kŽe‚û¹‡p õÄÞ+¿K`qâ>˜á‚ê4ÿ”A–F®p¦i	”àAÕàop/üz14JÚ€V6Á¶Ä‡ð¹W²ò6Ç,“™¼6ì†7–DpÁ«öÁ ö÷+ÌCAJA
ü´ó†€qn,¦À;y“úÖò Oº­míÀ¡º¡ÌwŸ5ã>	–PnU®ăuÜÖ´-2pçèWÛ¯*¾“ÈŒ_ñÂüívþH»… „ôÞ$kZ4ÒI®YYqð‹	”SÊBèµ"˜luV¾ô×Ô‡¹Æ>'àqM#ðÁÖÃWz\G%¢Šü%8¹ã°qå\v%(•
ÐKãß<½h¯Éìí߃àø͐-­ðâß®QD%}|a*¾©à²‘ö‡n•Â«Ö‰Ò½v®ÿ@IJa¾&üv¶<X¼óôÿSeZ‹¸C[¥<É/A‚üû‘»0` :!©}'ÆWÄ>\—ïƒ"ˆMÍíZM;JwkNÅ!#¹¯Y'²ýsC‰°”bxÉ2nðæµÌ3ÎÌ‹égö<+Š{E•]ëÁ6„AÏA¨P%
+ª€Š†0öÜ/möCîã
+õÍ’Î6*»i„·Š
+1эÛxš=HN•p#·¦Û÷Z‘`¿K¼#¼3WÕs„„8i
+”ˆxlR˜`½Ð4PU´¶·CRf¾z$˜((î±Z0&»vöL%†Š7ž\.€ÑŽÐó·ÙCKQ"BI'˜L4áÂ"øTf9ŒvXG\¢ÑÕßoI'˜LD“yÁ
+œUÖ�ªŒìÒjH¸Êîå¦	F}_O.™¢hm8»¼T
iye ívÔM:³·gçxÁ†}Ø?¡ñ¯õ»¨G‘>¡ï\¡È
+óÕüPœÎȃ/­¥/ý¼¹,†h‚FêZ[:P>èoóálE&»“wË`¨‘ƒ20˝w›#ùv¡0s‡ß©Tž©ÀÇZÉéŬ,9D`¾Å‹˜•yË\<Ÿ¼È’”€¢˜f
+¸ˆ¼z éÊÛ+ü±Ûel`–ÁÑZÞ™8ÏâÓó@^$c
CMÓ¢’ð{tÊÒ¹HÎ(©ƒÁB^y-ü›ÓYLÝØ¢R(à½6æ„L¼¾Á}mdE´ÌáÌV/”‰ôü2˜ézT*Y~ñ#Ÿã`…qéG’Á!*V…%‚©ïIcâÙïqϘzÂÁ+™ NÜÌ¡Ú[Cd‚–u�«-9 3ÉNgr•3=®†9;Nr!WŸö:.DF&g°5µû.ô{ü�˜ìðA	FSÌœ1r°Õ#2CÓ²˜æÕl_îrÙv YÅ
+7á=ªn€å!	4~•æµlñ±Y¿P¾0(¾ßsià‡¸+1¤€Ôï¬ÂG{’¼ÿKèŸHr;NzìMYç‚]Àôye4­·öÿ M|}†ßEx\Û$ɱ®	¬#/
HìÖþ§»š2
+ÊÙêÎGàXHÿÖî Ìóƒ#×î3b¸%žWû
ô¡õ’<îØŸœѬ­	GúKD^ TØbfÒ!u‚0ñÌ{ïßE{n®7˜íˆ¥)*¯Ì)³¸\cRàÓÍü›ï¢¯g‡ÊúfÐõˆ–ú÷›÷'ÔèÎÏ>œS·òÑ(	ìýÞ[½‹õ=`XfJDôfAfjì- Enô¬Ã ç
óâ`†Kü}íŸtXÊz>v,Œe©GòZw;·("®dÉu¨¹´b¶ŸÝ?Ô‘ä|¬¸Ñ“«”}´!ŒŒ¨Tð>îÁ¬>üþš9º×è‚fzŽÏã(Å­¢*GòX3¯Á{?t2ÔÝÑÏÁ¡c÷
+ž¼ŠóäQfÜ£¼jq÷ÐÛDiвz‹ëqÂè"ì¯ƒñ¯ '¡å£6ò0E–ZË`uræ¹['‘Á
+½zóeœM•§­ÿ.	>Q× AJy}ÏÈÇšFfIeÿûÝ4-óñëd2$À”½¹3>ÁoÞ>*¹[Ö„Í!´ÓQáó>çX¼3RrJÆé=þÎ
Ёh–vž9ӐBÛfÕ'¼A	6ߝØWy’­Ü®Æ×0 õ_^õÞŸ0£³‰¼LK’½2³$\jŸò›ÏHA‚÷eIT¨Çð?ϲ¾Ù®BÌâZ‡(©Õ9$¼ƒeoËjX•íbfm(a-L¥5
ìõþ€é4M¥{H‚•„œÌÞ'ª¼0=œªð+p5Eí3˽›&H…Ž%%Ç®gÓ°{æ¨kO½¶
%oQuà«á…zŸ7µ ÑBIæÿ!°µ»Íé<ߎÖ>pæüO'ËÞ#Ö*'²—É°‚ÓxÔ±@ôèJ4'BÙ_ûÃÔu¡ÌÿÛzé¬–ëQ¶ÊñÁÚPܤØO%΍y,^W;ǪaÃè%DÛځ¥Ú83ãÏC—,hÛИMx{‘ØWɈÃ,»/P>bˆ&ÍŠliáD^­æ_˜a¤nnÁ+Ë‹‘Xu2€žüÞ4’mQȶ<!፰0Iæ¸ÚЕ‡ÇSBÙðG£#&G0pžŽ怤Ç#9¹ìa›3-ëñç4è4K$šE+Z#­náÏÙøZŽ7ü tØD«ø~I]Eí¸����IEND®B`‚
\ No newline at end of file
diff --git a/core/tests/fixtures/default_content/file/druplicon_copy.png b/core/tests/fixtures/default_content/file/druplicon_copy.png
new file mode 100644
index 0000000000000000000000000000000000000000..3b49a4ce78dc8b1ce754706f400b3b61a99857d1
--- /dev/null
+++ b/core/tests/fixtures/default_content/file/druplicon_copy.png
@@ -0,0 +1,18 @@
+‰PNG
+
+���
IHDR���X���d���-÷â��IDATxÚí]	PTW}€‰FͦÉĤ’Te2©ÔÄL*“Ô$•É:¥©LM1jÔ¸ÄwA6Ã*;"›H@\AEAQDÅEÁ]dß¡YîÜ÷œ/{ó»û74ðOÕ-–æÿþÿô}çÝ{ß}¢ò0tË$¦>ïʁºŽMѵo!¦Û'ÃÔwò"à••û€è;WÃÄ{•]Ôuøe3? ³Ý²ˆ1ÛýÒñôbM)²æ#4ÏSD„@˜õGkQM3$åV?!Ø<ˆc/o"BA˜øÌúÊzPd<ªg3[}ˆ®So"BºFÞ(�ŠÌ²FÐXßIòÊh Ú6€½žˆor{wÙhïè�ŠÛèÁc7œ¦äv'Yמ’¼ŽˆIƍ7rnK-¬Çn—QR{Ûªã€:M59„ˆàÓíS×µo=ŒÒÀ¡º©–GeöI0§É('OB¸9¾DD¿ž«ý‚‰s{ìGÀ¡¥­ö¥–Â.—(™RÅɳ¶¶¢dXݼvz`Ö'ëC »¼8T5¶Bȵ0O—Fl/]f±²¾S
Ö/¾'£&Þ+'9µyÇgà„
’vHÌ®ßKEðkÄP[KÉ“Ñ–¢çHcæÇHôdÔaÎöÐk³°Ûy•À¡ º‰-Ÿ‹OlšÿuJ˜ü¶ä %™zt-JÐ"2*`ìí2ÉØþLÊ‚¾p«¤þ)Á®çò)QŠÛÊ@‚i‚ÒŒǶ‘*Æ97¯¿�eõ-ИDpÛÄçR‚„³Ç�%ˆ–ujuìH‰Œ1tnX…ÕM0nÕ=%ø‡ÀB‘Û;~6ݬÎlâm>\£ƒ7ˆá¶Bm×(¸÷¨x�=[~—‹¹Ž	ṍ,{Sž­8Jãg ×‰×ûÚ0™Àvh#÷›BNy=ð‹ywßxÄÈõN*„÷œ.RÇ–F�ѱbì¹WÕuÖd¬¡“dëÑ AÂøB‚1Ú¡ôÇO¥áçà›ƒG.g«OÒ”›®œTÓŒRÕä`<µ»Ÿo
+…ŒâZ8©Hë$×do¨Ñ*[´ÈL+ZD²Qr?TÓshqŒ¹	Xü’	µÍmœ,0[qÔ×Æ™ÒëúNT2b‡:B˜÷¼‰KG4.ëÈŠŠ	K…9rWË‚1\ÍWÌ<Ž.OÑBRÞ§ú$^Aï,ñ…Û%µ +Jj[ (¹ø)¹‹"ï³ëYÄ z6ןzs5JƸA$×3âk«=¬ #+r*šX#×`w:¨³zƒ
+Û¯¡tò«E’Ç¹a³=£iqF&4µ¶C\fÅSb½Ñ¦°DbxØü]”äJLõÕ•)þ¦ÛO ¹²±û ¼‚¯vJ‚WR|âqeøË™“‹"eŸî†^Ç¡M×mÂ,¶Ók™m8þ�^³97ìÈåŒM|ÚÖ»Å|´¾·¤Ã\n¯õF›šÆE
+ÃØâØŠ6®ž,ª?ìå·û¶×4óLz{­CÂCnÙgD›ô4-š‘dÅ»>ÇÏñ,={¿B+JÇõÂZ¿ºk­^Ø-˜°é̈!—«] S’‹ÈëÔ™ÔÂç'A³i šœV\‡rPÒÍk—¾STOk…+à¹?‘Š™["åÕÝÞúÍ—•û�#ýJ~
ø_)êFì–ؘºÉÁÈ6cÏ'¥Î›Ú‰Ží7DfèÚ7/‰Hg¤¾_	¥u-]Ñ)µq‹gº™KbüÇï:×â4òm®ß/6p¦^üXVip;?<.Pò¤ÓYCÌÆ&nf:;zÌŒÌ=˜FË	oè9JÞ´=+•XO$ξxÝ–éìè³9¾ÁL*ЋËøzo�®JÀ³Ølçv>¿±6§ráû€\d0zÍÔ‡’ÛÝ‹µmÖòizn#«bØI^´L€¯}¯ÁŒàTøÔ3&m9K/7Éu%x¶+Nx›«¥†mxP8Ö>Eòx,ÿ#©½MËšJÅÒ/tìÚñ`‘Àì·ý}làB½¸¢¿bÎz,dˆäñ,]"¡}ÛLKQ|GzÙ/&ˉäñYJ2ñêŸ`}G*ñ=.Ÿ!Ú¶£ž<>†NˆDJ5Ìî{6ä9a,'’ÇK‚&˜É„Ýô®;zî“!"y¼Š<ŒL#Š½]c_‰=ä½\}BÓ"Óß—0<Éãûò·Ÿ7¶±¤Ã3Ô`‘Àl^,s:üAM9‰K "ÒlY¤lär:¬m³…ÐÖ ÌLDû³U1\Ü+“±M8ZV‡	~SG–‰ì;©à
+ë²+Ä[¦ÓúC†"™}Ù‚J–|f¸Ö%Ê	Ûb'’ÙÓï‘›\®Ï2:1E–R-SАàšdˆ„vµ¥…!
%¢•Nr"©œÑÉÞÈCH‚Û‘`§¡¸Öl–kŽe‚û¹‡p õÄÞ+¿K`qâ>˜á‚ê4ÿ”A–F®p¦i	”àAÕàop/üz14JÚ€V6Á¶Ä‡ð¹W²ò6Ç,“™¼6ì†7–DpÁ«öÁ ö÷+ÌCAJA
ü´ó†€qn,¦À;y“úÖò Oº­míÀ¡º¡ÌwŸ5ã>	–PnU®ăuÜÖ´-2pçèWÛ¯*¾“ÈŒ_ñÂüívþH»… „ôÞ$kZ4ÒI®YYqð‹	”SÊBèµ"˜luV¾ô×Ô‡¹Æ>'àqM#ðÁÖÃWz\G%¢Šü%8¹ã°qå\v%(•
ÐKãß<½h¯Éìí߃àø͐-­ðâß®QD%}|a*¾©à²‘ö‡n•Â«Ö‰Ò½v®ÿ@IJa¾&üv¶<X¼óôÿSeZ‹¸C[¥<É/A‚üû‘»0` :!©}'ÆWÄ>\—ïƒ"ˆMÍíZM;JwkNÅ!#¹¯Y'²ýsC‰°”bxÉ2nðæµÌ3ÎÌ‹égö<+Š{E•]ëÁ6„AÏA¨P%
+ª€Š†0öÜ/möCîã
+õÍ’Î6*»i„·Š
+1эÛxš=HN•p#·¦Û÷Z‘`¿K¼#¼3WÕs„„8i
+”ˆxlR˜`½Ð4PU´¶·CRf¾z$˜((î±Z0&»vöL%†Š7ž\.€ÑŽÐó·ÙCKQ"BI'˜L4áÂ"øTf9ŒvXG\¢ÑÕßoI'˜LD“yÁ
+œUÖ�ªŒìÒjH¸Êîå¦	F}_O.™¢hm8»¼T
iye ívÔM:³·gçxÁ†}Ø?¡ñ¯õ»¨G‘>¡ï\¡È
+óÕüPœÎȃ/­¥/ý¼¹,†h‚FêZ[:P>èoóálE&»“wË`¨‘ƒ20˝w›#ùv¡0s‡ß©Tž©ÀÇZÉéŬ,9D`¾Å‹˜•yË\<Ÿ¼È’”€¢˜f
+¸ˆ¼z éÊÛ+ü±Ûel`–ÁÑZÞ™8ÏâÓó@^$c
CMÓ¢’ð{tÊÒ¹HÎ(©ƒÁB^y-ü›ÓYLÝØ¢R(à½6æ„L¼¾Á}mdE´ÌáÌV/”‰ôü2˜ézT*Y~ñ#Ÿã`…qéG’Á!*V…%‚©ïIcâÙïqϘzÂÁ+™ NÜÌ¡Ú[Cd‚–u�«-9 3ÉNgr•3=®†9;Nr!WŸö:.DF&g°5µû.ô{ü�˜ìðA	FSÌœ1r°Õ#2CÓ²˜æÕl_îrÙv YÅ
+7á=ªn€å!	4~•æµlñ±Y¿P¾0(¾ßsià‡¸+1¤€Ôï¬ÂG{’¼ÿKèŸHr;NzìMYç‚]Àôye4­·öÿ M|}†ßEx\Û$ɱ®	¬#/
HìÖþ§»š2
+ÊÙêÎGàXHÿÖî Ìóƒ#×î3b¸%žWû
ô¡õ’<îØŸœѬ­	GúKD^ TØbfÒ!u‚0ñÌ{ïßE{n®7˜íˆ¥)*¯Ì)³¸\cRàÓÍü›ï¢¯g‡ÊúfÐõˆ–ú÷›÷'ÔèÎÏ>œS·òÑ(	ìýÞ[½‹õ=`XfJDôfAfjì- Enô¬Ã ç
óâ`†Kü}íŸtXÊz>v,Œe©GòZw;·("®dÉu¨¹´b¶ŸÝ?Ô‘ä|¬¸Ñ“«”}´!ŒŒ¨Tð>îÁ¬>üþš9º×è‚fzŽÏã(Å­¢*GòX3¯Á{?t2ÔÝÑÏÁ¡c÷
+ž¼ŠóäQfÜ£¼jq÷ÐÛDiвz‹ëqÂè"ì¯ƒñ¯ '¡å£6ò0E–ZË`uræ¹['‘Á
+½zóeœM•§­ÿ.	>Q× AJy}ÏÈÇšFfIeÿûÝ4-óñëd2$À”½¹3>ÁoÞ>*¹[Ö„Í!´ÓQáó>çX¼3RrJÆé=þÎ
Ёh–vž9ӐBÛfÕ'¼A	6ߝØWy’­Ü®Æ×0 õ_^õÞŸ0£³‰¼LK’½2³$\jŸò›ÏHA‚÷eIT¨Çð?ϲ¾Ù®BÌâZ‡(©Õ9$¼ƒeoËjX•íbfm(a-L¥5
ìõþ€é4M¥{H‚•„œÌÞ'ª¼0=œªð+p5Eí3˽›&H…Ž%%Ç®gÓ°{æ¨kO½¶
%oQuà«á…zŸ7µ ÑBIæÿ!°µ»Íé<ߎÖ>pæüO'ËÞ#Ö*'²—É°‚ÓxÔ±@ôèJ4'BÙ_ûÃÔu¡ÌÿÛzé¬–ëQ¶ÊñÁÚPܤØO%΍y,^W;ǪaÃè%DÛځ¥Ú83ãÏC—,hÛИMx{‘ØWɈÃ,»/P>bˆ&ÍŠliáD^­æ_˜a¤nnÁ+Ë‹‘Xu2€žüÞ4’mQȶ<!፰0Iæ¸ÚЕ‡ÇSBÙðG£#&G0pžŽ怤Ç#9¹ìa›3-ëñç4è4K$šE+Z#­náÏÙøZŽ7ü tØD«ø~I]Eí¸����IEND®B`‚
\ No newline at end of file
diff --git a/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml b/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d54c09c76931f633ea7ec0ba6f39b003393e1aeb
--- /dev/null
+++ b/core/tests/fixtures/default_content/media/344b943c-b231-4d73-9669-0b0a2be12aa5.yml
@@ -0,0 +1,38 @@
+_meta:
+  version: '1.0'
+  entity_type: media
+  uuid: 344b943c-b231-4d73-9669-0b0a2be12aa5
+  bundle: image
+  default_langcode: en
+  depends:
+    d8404562-efcc-40e3-869e-40132d53fe0b: file
+default:
+  revision_user:
+    -
+      target_id: 1
+  status:
+    -
+      value: true
+  uid:
+    -
+      target_id: 1
+  name:
+    -
+      value: druplicon.png
+  created:
+    -
+      value: 1711121695
+  revision_translation_affected:
+    -
+      value: true
+  path:
+    -
+      alias: ''
+      langcode: en
+  field_media_image:
+    -
+      entity: d8404562-efcc-40e3-869e-40132d53fe0b
+      alt: 'A Druplicon on a transparent background.'
+      title: ''
+      width: 88
+      height: 100
diff --git a/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml b/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml
new file mode 100644
index 0000000000000000000000000000000000000000..eecf8562c0cc06a7314a0f5b485ee6550ff5f207
--- /dev/null
+++ b/core/tests/fixtures/default_content/menu_link_content/3434bd5a-d2cd-4f26-bf79-a7f6b951a21b.yml
@@ -0,0 +1,38 @@
+_meta:
+  version: '1.0'
+  entity_type: menu_link_content
+  uuid: 3434bd5a-d2cd-4f26-bf79-a7f6b951a21b
+  bundle: menu_link_content
+  default_langcode: en
+  depends:
+    e1714f23-70c0-4493-8e92-af1901771921: node
+default:
+  enabled:
+    -
+      value: true
+  title:
+    -
+      value: 'Test Article'
+  menu_name:
+    -
+      value: main
+  link:
+    -
+      target_uuid: e1714f23-70c0-4493-8e92-af1901771921
+      title: ''
+      options: {  }
+  external:
+    -
+      value: false
+  rediscover:
+    -
+      value: false
+  weight:
+    -
+      value: 0
+  expanded:
+    -
+      value: false
+  revision_translation_affected:
+    -
+      value: true
diff --git a/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml b/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c29e6b14a492f19c27089008212c64904d659c44
--- /dev/null
+++ b/core/tests/fixtures/default_content/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml
@@ -0,0 +1,83 @@
+_meta:
+  version: '1.0'
+  entity_type: node
+  uuid: 2d3581c3-92c7-4600-8991-a0d4b3741198
+  bundle: article
+  default_langcode: en
+  depends:
+    94503467-be7f-406c-9795-fc25baa22203: user
+default:
+  revision_uid:
+    -
+      target_id: 1
+  status:
+    -
+      value: true
+  uid:
+    -
+      entity: 94503467-be7f-406c-9795-fc25baa22203
+  title:
+    -
+      value: 'Lost in translation'
+  created:
+    -
+      value: 1711976268
+  promote:
+    -
+      value: true
+  sticky:
+    -
+      value: false
+  path:
+    -
+      alias: ''
+      langcode: en
+  content_translation_source:
+    -
+      value: und
+  content_translation_outdated:
+    -
+      value: false
+  body:
+    -
+      value: "Here's the English version."
+      format: plain_text
+      summary: ''
+translations:
+  fr:
+    status:
+      -
+        value: true
+    uid:
+      -
+        target_id: 1
+    title:
+      -
+        value: 'Perdu en traduction'
+    created:
+      -
+        value: 1711976291
+    promote:
+      -
+        value: true
+    sticky:
+      -
+        value: false
+    revision_translation_affected:
+      -
+        value: true
+    path:
+      -
+        alias: ''
+        langcode: fr
+    content_translation_source:
+      -
+        value: en
+    content_translation_outdated:
+      -
+        value: false
+    body:
+      -
+        value: "Içi c'est la version français."
+        format: plain_text
+        summary: ''
diff --git a/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml b/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml
new file mode 100644
index 0000000000000000000000000000000000000000..023a176830494f741cad1c91c352f936c8bba156
--- /dev/null
+++ b/core/tests/fixtures/default_content/node/7f1dd75a-0be2-4d3b-be5d-9d1a868b9267.yml
@@ -0,0 +1,44 @@
+_meta:
+  version: '1.0'
+  entity_type: node
+  uuid: 7f1dd75a-0be2-4d3b-be5d-9d1a868b9267
+  bundle: page
+  default_langcode: en
+  depends:
+    # This user does not actually exist; this lets us test that the node
+    # will be assigned to user 1 during the import.
+    e2b1b3fb-27ea-41ec-b70f-dbf2907fb658: user
+default:
+  revision_uid:
+    -
+      target_id: 1
+  status:
+    -
+      value: true
+  uid:
+    -
+      entity: e2b1b3fb-27ea-41ec-b70f-dbf2907fb658
+  title:
+    -
+      value: 'No Owner'
+  created:
+    -
+      value: 1711638565
+  promote:
+    -
+      value: false
+  sticky:
+    -
+      value: false
+  revision_translation_affected:
+    -
+      value: true
+  path:
+    -
+      alias: ''
+      langcode: en
+  body:
+    -
+      value: 'This page was authored by a non-existent user.'
+      format: plain_text
+      summary: ''
diff --git a/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml b/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml
new file mode 100644
index 0000000000000000000000000000000000000000..883cdfe15ad40f809f8d142d056ae9f7c16072d2
--- /dev/null
+++ b/core/tests/fixtures/default_content/node/e1714f23-70c0-4493-8e92-af1901771921.yml
@@ -0,0 +1,46 @@
+_meta:
+  version: '1.0'
+  entity_type: node
+  uuid: e1714f23-70c0-4493-8e92-af1901771921
+  bundle: article
+  default_langcode: en
+  depends:
+    94503467-be7f-406c-9795-fc25baa22203: user
+    550f86ad-aa11-4047-953f-636d42889f85: taxonomy_term
+default:
+  revision_uid:
+    -
+      target_id: 1
+  status:
+    -
+      value: true
+  uid:
+    -
+      entity: 94503467-be7f-406c-9795-fc25baa22203
+  title:
+    -
+      value: 'Test Article'
+  created:
+    -
+      value: 1711476803
+  promote:
+    -
+      value: true
+  sticky:
+    -
+      value: false
+  revision_translation_affected:
+    -
+      value: true
+  path:
+    -
+      alias: /test-article
+      langcode: en
+  body:
+    -
+      value: 'Crikey it works!'
+      format: plain_text
+      summary: ''
+  field_tags:
+    -
+      entity: 550f86ad-aa11-4047-953f-636d42889f85
diff --git a/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml b/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml
new file mode 100644
index 0000000000000000000000000000000000000000..be025a848a3dca5c6df692349f5c12884b56ce7d
--- /dev/null
+++ b/core/tests/fixtures/default_content/taxonomy_term/550f86ad-aa11-4047-953f-636d42889f85.yml
@@ -0,0 +1,31 @@
+_meta:
+  version: '1.0'
+  entity_type: taxonomy_term
+  uuid: 550f86ad-aa11-4047-953f-636d42889f85
+  bundle: tags
+  default_langcode: en
+default:
+  status:
+    -
+      value: true
+  name:
+    -
+      value: 'Default Content'
+  weight:
+    -
+      value: 0
+  parent:
+    -
+      target_id: 0
+  revision_translation_affected:
+    -
+      value: true
+  path:
+    -
+      alias: ''
+      langcode: en
+  field_serialized_stuff:
+    -
+      value:
+        - Hi
+        - there!
diff --git a/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml b/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8f1632105ebb2b543b86f46f8e430e6fccbcd2bc
--- /dev/null
+++ b/core/tests/fixtures/default_content/user/94503467-be7f-406c-9795-fc25baa22203.yml
@@ -0,0 +1,43 @@
+_meta:
+  version: '1.0'
+  entity_type: user
+  uuid: 94503467-be7f-406c-9795-fc25baa22203
+  default_langcode: en
+default:
+  preferred_langcode:
+    -
+      value: en
+  preferred_admin_langcode:
+    -
+      value: en
+  name:
+    -
+      value: 'Naomi Malone'
+  pass:
+    -
+      # cspell:disable
+      value: $2y$10$3GlpQmjbJ9raJNQ.JZmg/OVS7avJ7KPQxucunwovUtOvpKbe3k8lK
+      # cspell:enable
+      existing: ''
+      pre_hashed: false
+  mail:
+    -
+      value: author@example.com
+  timezone:
+    -
+      value: UTC
+  status:
+    -
+      value: true
+  created:
+    -
+      value: 1711125883
+  access:
+    -
+      value: 0
+  login:
+    -
+      value: 0
+  init:
+    -
+      value: author@example.com
diff --git a/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6d9589f9c3b67dc289b065c3b14d05bf117156f8
--- /dev/null
+++ b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml
@@ -0,0 +1,6 @@
+name: 'Base theme and views'
+type: 'Testing'
+install:
+  - test_subsubtheme
+  - node
+  - views
diff --git a/core/tests/fixtures/recipes/config_actions/recipe.yml b/core/tests/fixtures/recipes/config_actions/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4e16eeb67b7329d1ab22fc9b0cfe6007d8be7121
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions/recipe.yml
@@ -0,0 +1,13 @@
+name: 'Config actions'
+type: 'Testing'
+install:
+  - config_test
+config:
+  actions:
+    config_test.dynamic.recipe:
+      ensure_exists:
+        label: 'Created by recipe'
+      setProtectedProperty: 'Set by recipe'
+    config_test.system:
+      simple_config_update:
+        foo: 'not bar'
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..369e09357e7c48d44aafe551ec14fbf1dfb895b2
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/direct_dependency/recipe.yml
@@ -0,0 +1,9 @@
+name: Recipe with direct dependency present
+type: 'Testing'
+install:
+  - node
+config:
+  actions:
+    node.settings:
+      simple_config_update:
+        use_admin_theme: true
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f6ebd037e865d7822a6de0243e74b25582f542b6
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_one_level_down/recipe.yml
@@ -0,0 +1,9 @@
+name: Recipe with first level indirect dependency
+type: 'Testing'
+recipes:
+  - level_2
+config:
+  actions:
+    node.settings:
+      simple_config_update:
+        use_admin_theme: true
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ac2e9bdef7cd247bb01ec64689eb19f86c10ca77
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/indirect_dependency_two_levels_down/recipe.yml
@@ -0,0 +1,9 @@
+name: Recipe with second level indirect dependency
+type: 'Testing'
+recipes:
+  - level_1
+config:
+  actions:
+    node.settings:
+      simple_config_update:
+        use_admin_theme: true
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3f902e7f419f14820f18308c5cfe7e57506166b5
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_1/recipe.yml
@@ -0,0 +1,4 @@
+name: First level sub recipe
+type: 'Testing'
+recipes:
+  - level_2
diff --git a/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5e467f72ccf7e27bf4bf51e01d8d78fa8488e4e5
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_actions_dependency_validation/level_2/recipe.yml
@@ -0,0 +1,4 @@
+name: Second level sub recipe
+type: 'Testing'
+install:
+  - node
diff --git a/core/tests/fixtures/recipes/config_from_module/recipe.yml b/core/tests/fixtures/recipes/config_from_module/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f88aa486c1ccccab9ab3f1c94e79ff87c0ce1862
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_from_module/recipe.yml
@@ -0,0 +1,9 @@
+name: 'Config from module'
+type: 'Testing'
+install:
+  - config_test
+config:
+  import:
+    config_test:
+      - config_test.dynamic.dotted.default
+      - config_test.dynamic.override
diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ce5eb672c3de9e671fc4db8a065bd1efc05cb7c8
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml
@@ -0,0 +1,6 @@
+id: dotted.default
+label: 'Provided by recipe'
+weight: 0
+protected_property: Default
+# Intentionally commented out to verify default status behavior.
+# status: 1
diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5aa1d937ae256d5960abb2dd24e0fe72dab64624
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml
@@ -0,0 +1,2 @@
+foo: bar
+404: foo
diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..58771fd7b60ed977add15646a1a40a4c31303391
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml
@@ -0,0 +1,11 @@
+name: 'Config from module and recipe'
+type: 'Testing'
+install:
+  - config_test
+  - shortcut
+  - system
+config:
+  import:
+    config_test: '*'
+    shortcut:
+      - shortcut.set.default
diff --git a/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml b/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2bf0db3433cbf109b991ff98ce784a7fafcbfa65
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_rollback_exception/recipe.yml
@@ -0,0 +1,19 @@
+name: Config rollback exception
+install:
+  - filter
+  - media
+config:
+  import:
+    filter: '*'
+    media: '*'
+  actions:
+    filter.format.plain_text:
+      setFilterConfig:
+        instance_id: media_embed
+        configuration: []
+    system.image:
+      # This will cause a validation error, which will trigger a rollback.
+      # The rollback should fail, since the Media module can't be uninstalled
+      # now that the plain_text format is using one of its filters.
+      simple_config_update:
+        non_existent_key: whatever!
diff --git a/core/tests/fixtures/recipes/config_wildcard/recipe.yml b/core/tests/fixtures/recipes/config_wildcard/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9c4e68af2490feba1adb30326c9c6b1ba91f21f0
--- /dev/null
+++ b/core/tests/fixtures/recipes/config_wildcard/recipe.yml
@@ -0,0 +1,10 @@
+name: 'Config wildcard'
+type: 'Testing'
+install:
+  - config_test
+  - shortcut
+  - system
+config:
+  import:
+    config_test: '*'
+    shortcut: ~
diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6cb95cbc42a2670a4bdc9245a1e27f351bf88a9d
--- /dev/null
+++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml
@@ -0,0 +1 @@
+use_admin_theme: true
diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..adffa484d4b1b7edd71c485324548cdff6b982a1
--- /dev/null
+++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+name: 'Test content type'
+type: test
+description: 'Test content type from a recipe'
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: true
diff --git a/core/tests/fixtures/recipes/install_node_with_config/recipe.yml b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e2fc243b529bd1b0afcc734e522a2abafbd176d1
--- /dev/null
+++ b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Install node with config'
+type: 'Content type'
+install:
+  - node
+  - drupal:text
diff --git a/core/tests/fixtures/recipes/install_two_modules/recipe.yml b/core/tests/fixtures/recipes/install_two_modules/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ee57ca146df305b9c487aa017751ff60244b0093
--- /dev/null
+++ b/core/tests/fixtures/recipes/install_two_modules/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Install two modules'
+type: 'Content type'
+install:
+  - node
+  - text
diff --git a/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6bfb62960d22137a0b66f8d3a86f5a3ba03f45bc
--- /dev/null
+++ b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.invalid.yml
@@ -0,0 +1,14 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    # Depend on the valid date format in order to ensure it is imported first,
+    # which means we can ensure it was rolled back when this date format raises
+    # a validation error.
+    - core.date_format.valid
+id: invalid
+# Null isn't a valid value for the label, so this should raise a validation
+# error.
+label: null
+locked: false
+pattern: 'F j, Y'
diff --git a/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7e4468198319251b5aca0d5b0e1602c998f27ab3
--- /dev/null
+++ b/core/tests/fixtures/recipes/invalid_config/config/core.date_format.valid.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+id: valid
+label: 'Valid date format'
+locked: false
+pattern: 'F j, Y'
diff --git a/core/tests/fixtures/recipes/invalid_config/recipe.yml b/core/tests/fixtures/recipes/invalid_config/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5f5cb88e788bd1035e78f183c6fa2aa30c37cc25
--- /dev/null
+++ b/core/tests/fixtures/recipes/invalid_config/recipe.yml
@@ -0,0 +1,2 @@
+name: 'Invalid config'
+type: 'Testing'
diff --git a/core/tests/fixtures/recipes/no_extensions/recipe.yml b/core/tests/fixtures/recipes/no_extensions/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b7d3aeb4f0900f33640f9c27ba9021cca569c9ea
--- /dev/null
+++ b/core/tests/fixtures/recipes/no_extensions/recipe.yml
@@ -0,0 +1,3 @@
+name: 'No extensions'
+description: 'A recipe description'
+type: 'Testing'
diff --git a/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml b/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c2cd6b73cd00258e9c77312d437bac40541219d5
--- /dev/null
+++ b/core/tests/fixtures/recipes/recipe_depend_on_invalid/recipe.yml
@@ -0,0 +1,4 @@
+name: 'Recipe depending on an invalid recipe'
+type: 'Testing'
+recipes:
+  - invalid_config
diff --git a/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml b/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5e487e35a7954e933e04ce60ec5d9235b55c3865
--- /dev/null
+++ b/core/tests/fixtures/recipes/recipe_depend_on_invalid_config_and_valid_modules/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Recipe depending on first installing modules, then a recipe with invalid config'
+type: 'Testing'
+recipes:
+  - install_two_modules
+  - invalid_config
diff --git a/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..884f144dc778e4452ac051a0231979a8a577d0ed
--- /dev/null
+++ b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+name: 'Another test content type'
+type: another_test
+description: 'Another test content type from a recipe'
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: true
diff --git a/core/tests/fixtures/recipes/recipe_include/recipe.yml b/core/tests/fixtures/recipes/recipe_include/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a81aa075c09e3d54eba17ce06022a1f5b89898f0
--- /dev/null
+++ b/core/tests/fixtures/recipes/recipe_include/recipe.yml
@@ -0,0 +1,6 @@
+name: 'Recipe include'
+type: 'Testing'
+recipes:
+  - install_node_with_config
+install:
+  - dblog
diff --git a/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..550c3610a95fae1be2991019e3ef91012e73913d
--- /dev/null
+++ b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml
@@ -0,0 +1,5 @@
+name: 'Theme with module dependencies'
+type: 'Testing'
+install:
+  - test_theme_depending_on_modules
+  - test_module_required_by_theme
diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..93a92f106d62a8ed6ae225bf3e34a45c505a9ee2
--- /dev/null
+++ b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml
@@ -0,0 +1,12 @@
+langcode: en
+status: true
+name: 'Test content type'
+type: test
+description: 'Test content type from a recipe'
+help: null
+new_revision: true
+preview_mode: 1
+display_submitted: true
+dependencies:
+  config:
+    - core.date_format.non_existent
diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e90fca915780ec3206d385c9ad9fc435a8a42655
--- /dev/null
+++ b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml
@@ -0,0 +1,2 @@
+name: 'Unmet config dependencies'
+type: 'Testing'