diff --git a/core/drupalci.yml b/core/drupalci.yml
index 6d04a45a75..24df079ae5 100644
--- a/core/drupalci.yml
+++ b/core/drupalci.yml
@@ -3,54 +3,9 @@
 # https://www.drupal.org/drupalorg/docs/drupal-ci/customizing-drupalci-testing
 build:
   assessment:
-    validate_codebase:
-      phplint:
-      eslint:
-        # A test must pass eslinting standards check in order to continue processing.
-        halt-on-fail: false
-      phpcs:
-        # phpcs will use core's specified version of Coder.
-        sniff-all-files: false
-        halt-on-fail: false
     testing:
-      # run_tests task is executed several times in order of performance speeds.
-      # halt-on-fail can be set on the run_tests tasks in order to fail fast.
-      # suppress-deprecations is false in order to be alerted to usages of
-      # deprecated code.
-      run_tests.phpunit:
-        types: 'PHPUnit-Unit'
-        testgroups: '--all'
-        suppress-deprecations: false
-        halt-on-fail: false
-      run_tests.kernel:
-        types: 'PHPUnit-Kernel'
-        testgroups: '--all'
-        suppress-deprecations: false
-        halt-on-fail: false
-      run_tests.build:
-        # Limit concurrency due to disk space concerns.
-        concurrency: 15
-        types: 'PHPUnit-Build'
-        testgroups: '--all'
-        suppress-deprecations: false
-        halt-on-fail: false
       run_tests.functional:
         types: 'PHPUnit-Functional'
-        testgroups: '--all'
-        suppress-deprecations: false
-        halt-on-fail: false
-      run_tests.javascript:
-        concurrency: 15
-        types: 'PHPUnit-FunctionalJavascript'
-        testgroups: '--all'
+        testgroups: '--class "Drupal\Tests\update\Functional\PsaTest"'
         suppress-deprecations: false
         halt-on-fail: false
-      # Run nightwatch testing.
-      # @see https://www.drupal.org/project/drupal/issues/2869825
-      nightwatchjs:
-      # Re-run Composer plugin tests after installing Composer 2
-      container_command.composer-upgrade:
-        commands:
-          - "sudo composer self-update --snapshot"
-          - "./vendor/bin/phpunit -c core --group VendorHardening,ProjectMessage,Scaffold"
-        halt-on-fail: true
diff --git a/core/lib/Drupal/Core/Cache/DatabaseBackend.php b/core/lib/Drupal/Core/Cache/DatabaseBackend.php
index 879ffae669..bc00e2e236 100644
--- a/core/lib/Drupal/Core/Cache/DatabaseBackend.php
+++ b/core/lib/Drupal/Core/Cache/DatabaseBackend.php
@@ -151,7 +151,7 @@ protected function prepareItem($cache, $allow_invalid) {
     $cache->tags = $cache->tags ? explode(' ', $cache->tags) : [];
 
     // Check expire time.
-    $cache->valid = $cache->expire == Cache::PERMANENT || $cache->expire >= REQUEST_TIME;
+    $cache->valid = $cache->expire == Cache::PERMANENT || $cache->expire >= \Drupal::time()->getRequestTime();
 
     // Check if invalidateTags() has been called with any of the items's tags.
     if (!$this->checksumProvider->isValid($cache->checksum, $cache->tags)) {
@@ -336,7 +336,7 @@ public function invalidateMultiple(array $cids) {
       // Update in chunks when a large array is passed.
       foreach (array_chunk($cids, 1000) as $cids_chunk) {
         $this->connection->update($this->bin)
-          ->fields(['expire' => REQUEST_TIME - 1])
+          ->fields(['expire' => \Drupal::time()->getRequestTime() - 1])
           ->condition('cid', $cids_chunk, 'IN')
           ->execute();
       }
@@ -352,7 +352,7 @@ public function invalidateMultiple(array $cids) {
   public function invalidateAll() {
     try {
       $this->connection->update($this->bin)
-        ->fields(['expire' => REQUEST_TIME - 1])
+        ->fields(['expire' => \Drupal::time()->getRequestTime() - 1])
         ->execute();
     }
     catch (\Exception $e) {
@@ -383,7 +383,7 @@ public function garbageCollection() {
 
       $this->connection->delete($this->bin)
         ->condition('expire', Cache::PERMANENT, '<>')
-        ->condition('expire', REQUEST_TIME, '<')
+        ->condition('expire', \Drupal::time()->getRequestTime(), '<')
         ->execute();
     }
     catch (\Exception $e) {
diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php
index e13450db2a..59462b2cc6 100644
--- a/core/modules/media_library/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php
+++ b/core/modules/media_library/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php
@@ -171,8 +171,8 @@ public function testConfigurationValidation() {
 
     $targetSelector = 'ul.ckeditor-toolbar-group-buttons';
     $buttonSelector = 'li[data-drupal-ckeditor-button-name="DrupalMediaLibrary"]';
-    $this->assertNotEmpty($assert_session->waitForElementVisible('css', $targetSelector));
-    $this->assertNotEmpty($assert_session->elementExists('css', $buttonSelector));
+    $this->assertNotEmpty($target = $assert_session->waitForElementVisible('css', $targetSelector));
+    $this->assertNotEmpty($button = $assert_session->elementExists('css', $buttonSelector));
     $this->sortableTo($buttonSelector, 'ul.ckeditor-available-buttons', $targetSelector);
     $page->pressButton('Save configuration');
     $assert_session->pageTextContains('The Embed media filter must be enabled to use the Insert from Media Library button.');
diff --git a/core/modules/update/config/install/update.settings.yml b/core/modules/update/config/install/update.settings.yml
index ded8c9d24c..914efb5f38 100644
--- a/core/modules/update/config/install/update.settings.yml
+++ b/core/modules/update/config/install/update.settings.yml
@@ -8,3 +8,9 @@ fetch:
 notification:
   emails: {  }
   threshold: all
+psa:
+  endpoint: 'https://updates.drupal.org/psa.json'
+  enable: true
+  notify: true
+  check_frequency: 43200
+
diff --git a/core/modules/update/config/schema/update.schema.yml b/core/modules/update/config/schema/update.schema.yml
index e9da9268e7..247b2b15b0 100644
--- a/core/modules/update/config/schema/update.schema.yml
+++ b/core/modules/update/config/schema/update.schema.yml
@@ -40,3 +40,19 @@ update.settings:
         threshold:
           type: string
           label: 'Email notification threshold'
+    psa:
+      type: mapping
+      label: 'PSA settings'
+      mapping:
+        endpoint:
+          type: string
+          label: 'Endpoint URI for PSAs'
+        enable:
+          type: boolean
+          label: 'Enable PSA notices'
+        notify:
+          type: boolean
+          label: 'Notify when PSAs are available'
+        check_frequency:
+          type: integer
+          label: 'Frequency to check for PSAs, defaults to 12 hours'
diff --git a/core/modules/update/src/Psa/EmailNotify.php b/core/modules/update/src/Psa/EmailNotify.php
new file mode 100644
index 0000000000..4986920a26
--- /dev/null
+++ b/core/modules/update/src/Psa/EmailNotify.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Drupal\update\Psa;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Drupal\Core\Mail\MailManagerInterface;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\PluralTranslatableMarkup;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * An implementation of the NotifyInterface which uses email for notification.
+ */
+class EmailNotify implements NotifyInterface {
+  use StringTranslationTrait;
+
+  private const LAST_MESSAGES_STATE_KEY = 'update_psa.last_messages_hash';
+
+  /**
+   * Mail manager.
+   *
+   * @var \Drupal\Core\Mail\MailManagerInterface
+   */
+  protected $mailManager;
+
+  /**
+   * The automatic updates service.
+   *
+   * @var \Drupal\update\Psa\UpdatesPsaInterface
+   */
+  protected $updatesPsa;
+
+  /**
+   * The config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The language manager.
+   *
+   * @var \Drupal\Core\Language\LanguageManagerInterface
+   */
+  protected $languageManager;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * Entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The logger.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * EmailNotify constructor.
+   *
+   * @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
+   *   The mail manager.
+   * @param \Drupal\update\Psa\UpdatesPsaInterface $updates_psa
+   *   The automatic updates service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
+   *   The language manager.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   Entity type manager.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   * @param \Psr\Log\LoggerInterface $logger
+   *   The logger.
+   */
+  public function __construct(MailManagerInterface $mail_manager, UpdatesPsaInterface $updates_psa, ConfigFactoryInterface $config_factory, LanguageManagerInterface $language_manager, StateInterface $state, TimeInterface $time, EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation, LoggerInterface $logger) {
+    $this->mailManager = $mail_manager;
+    $this->updatesPsa = $updates_psa;
+    $this->configFactory = $config_factory;
+    $this->languageManager = $language_manager;
+    $this->state = $state;
+    $this->time = $time;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->stringTranslation = $string_translation;
+    $this->logger = $logger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function send() {
+    $notify_emails = $this->configFactory->get('update.settings')->get('notification.emails');
+    // Don't send mail if notifications are disabled.
+    if (!$notify_emails || !$this->configFactory->get('update.settings')->get('psa.notify')) {
+      return;
+    }
+    try {
+      $messages = $this->updatesPsa->getPublicServiceMessages();
+    }
+    catch (\Exception $exception) {
+      $this->logger->error($this->t(
+        'Unable to send notification email because of error retrieving PSA feed: @error'),
+        ['@error' => UpdatesPsa::getErrorMessageFromException($exception, FALSE)]
+      );
+      return;
+    }
+
+    if (!$messages) {
+      return;
+    }
+
+    $messages_hash = hash('sha256', serialize($messages));
+    // Return if the messages are the same as the last messages sent.
+    if ($messages_hash === $this->state->get(static::LAST_MESSAGES_STATE_KEY)) {
+      return;
+    }
+
+    $params['subject'] = new PluralTranslatableMarkup(
+      count($messages),
+      '@count urgent Drupal announcement requires your attention for @site_name',
+      '@count urgent Drupal announcements require your attention for @site_name',
+      ['@site_name' => $this->configFactory->get('system.site')->get('name')]
+    );
+    $params['body'] = [
+      '#theme' => 'updates_psa_notify',
+      '#messages' => $messages,
+    ];
+    $default_langcode = $this->languageManager->getDefaultLanguage()->getId();
+    $params['langcode'] = $default_langcode;
+    foreach ($notify_emails as $notify_email) {
+      $this->doSend($notify_email, $params);
+    }
+    $this->state->set(static::LAST_MESSAGES_STATE_KEY, $messages_hash);
+  }
+
+  /**
+   * Composes and send the email message.
+   *
+   * @param string $email
+   *   The email address where the message will be sent.
+   * @param array $params
+   *   Parameters to build the email.
+   */
+  protected function doSend(string $email, array $params) {
+    /** @var \Drupal\user\UserInterface[] $users */
+    $users = $this->entityTypeManager->getStorage('user')
+      ->loadByProperties(['mail' => $email]);
+    if ($user = reset($users)) {
+      $params['langcode'] = $user->getPreferredLangcode();
+      $this->mailManager->mail('update', 'psa_notify', $email, $params['langcode'], $params);
+    }
+  }
+
+}
diff --git a/core/modules/update/src/Psa/NotifyInterface.php b/core/modules/update/src/Psa/NotifyInterface.php
new file mode 100644
index 0000000000..207ac37c35
--- /dev/null
+++ b/core/modules/update/src/Psa/NotifyInterface.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Drupal\update\Psa;
+
+/**
+ * Defines an interface for sending notification of update PSA's.
+ */
+interface NotifyInterface {
+
+  /**
+   * Send notification when PSAs are available.
+   */
+  public function send();
+
+}
diff --git a/core/modules/update/src/Psa/SecurityAnnouncement.php b/core/modules/update/src/Psa/SecurityAnnouncement.php
new file mode 100644
index 0000000000..996118d8dc
--- /dev/null
+++ b/core/modules/update/src/Psa/SecurityAnnouncement.php
@@ -0,0 +1,206 @@
+<?php
+
+namespace Drupal\update\Psa;
+
+use Symfony\Component\Validator\Constraints\Collection;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\Type;
+use Symfony\Component\Validator\Validation;
+
+/**
+ * A security announcement.
+ *
+ * These come form the PSA feed on drupal.org.
+ *
+ * @link https://www.drupal.org/docs/8/update/automatic-updates#s-public-service-announcement-psa-feed
+ */
+class SecurityAnnouncement {
+
+  /**
+   * The title of the announcement.
+   *
+   * @var string
+   */
+  protected $title;
+
+  /**
+   * The project name for the announcement.
+   *
+   * @var string
+   */
+  protected $project;
+
+  /**
+   * The project type for the announcement.
+   *
+   * @var string
+   */
+  protected $type;
+
+  /**
+   * Whether this announce is PSA instead of another type of announcement.
+   *
+   * @var bool
+   */
+  protected $isPsa;
+
+  /**
+   * The currently insecure versions of the project.
+   *
+   * @var array
+   */
+  protected $insecureVersions;
+
+  /**
+   * The link to the announcement.
+   *
+   * @var string
+   */
+  protected $link;
+
+  /**
+   * Constructs a SecurityAnnouncement object.
+   *
+   * @param string $title
+   *   The title of the announcement.
+   * @param string $project
+   *   The project name.
+   * @param string $type
+   *   The project type.
+   * @param bool $is_psa
+   *   Whether this announcement is a PSA.
+   * @param string $link
+   *   The link to the announcement.
+   * @param array $insecure_versions
+   *   The version of the project that currently insecure. For PSA's this is not
+   *   a list of versions that will be insecure when the security release is
+   *   published.
+   */
+  public function __construct(string $title, string $project, string $type, bool $is_psa, string $link, array $insecure_versions) {
+    $this->title = $title;
+    $this->project = $project;
+    $this->type = $type;
+    $this->isPsa = $is_psa;
+    $this->link = $link;
+    $this->insecureVersions = $insecure_versions;
+  }
+
+  /**
+   * Creates a SecurityAnnouncement instance from an array.
+   *
+   * @param array $data
+   *   The security announcement data as returned from the JSON feed.
+   *
+   * @return static
+   *   A new SecurityAnnouncement object.
+   *
+   * @throws \UnexpectedValueException
+   *   Thrown if the array is not a valid PSA.
+   */
+  public static function createFromArray(array $data) {
+    static::validateAnnouncementData($data);
+    return new static(
+      $data['title'],
+      $data['project'],
+      $data['type'],
+      $data['is_psa'],
+      $data['link'],
+      $data['insecure']
+    );
+  }
+
+  /**
+   * Validates the PSA data.
+   *
+   * @param array $data
+   *   The announcement data.
+   *
+   * @throws \UnexpectedValueException
+   *   Thrown if PSA data is not valid.
+   */
+  protected static function validateAnnouncementData(array $data): void {
+    $new_blank_constraints = [
+      new Type(['type' => 'string']),
+      new NotBlank(),
+    ];
+    $collection_constraint = new Collection([
+      'fields' => [
+        'title' => $new_blank_constraints,
+        'project' => $new_blank_constraints,
+        'type' => $new_blank_constraints,
+        'link' => $new_blank_constraints,
+        'is_psa' => new NotBlank(),
+        'insecure' => new Type(['type' => 'array']),
+      ],
+      'allowExtraFields' => TRUE,
+    ]);
+    $violations = Validation::createValidator()->validate($data, $collection_constraint);
+    if ($violations->count()) {
+      foreach ($violations as $violation) {
+        $volition_messages[] = (string) $violation;
+      }
+      throw new \UnexpectedValueException(implode(",  \n", $volition_messages));
+    }
+  }
+
+  /**
+   * Gets the title.
+   *
+   * @return string
+   *   The project title.
+   */
+  public function getTitle(): string {
+    return $this->title;
+  }
+
+  /**
+   * Gets the project associated with the announcement.
+   *
+   * @return string
+   *   The project name.
+   */
+  public function getProject(): string {
+    return $this->project;
+  }
+
+  /**
+   * Gets the type of project associated with the announcement.
+   *
+   * @return string
+   *   The project type.
+   */
+  public function getProjectType(): string {
+    return $this->type;
+  }
+
+  /**
+   * Whether the security announcement is PSA or not.
+   *
+   * @return bool
+   *   TRUE if the announcement is a PSA otherwise false.
+   */
+  public function isPsa(): bool {
+    return $this->isPsa;
+  }
+
+  /**
+   * Gets the currently insecure version of the project.
+   *
+   * @return string[]
+   *   The versions of the project that are currently insecure.
+   */
+  public function getInsecureVersions(): array {
+    return $this->insecureVersions;
+  }
+
+  /**
+   * Gets the links to the security announcement.
+   *
+   * @return string
+   *   The link.
+   */
+  public function getLink(): string {
+    return $this->link;
+  }
+
+}
diff --git a/core/modules/update/src/Psa/UpdatesPsa.php b/core/modules/update/src/Psa/UpdatesPsa.php
new file mode 100644
index 0000000000..941c989ce8
--- /dev/null
+++ b/core/modules/update/src/Psa/UpdatesPsa.php
@@ -0,0 +1,309 @@
+<?php
+
+namespace Drupal\update\Psa;
+
+use Composer\Semver\VersionParser;
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\DependencyInjection\DependencySerializationTrait;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\update\UpdateManagerInterface;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\TransferException;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Service class to get Public Service Messages.
+ */
+class UpdatesPsa implements UpdatesPsaInterface {
+  use StringTranslationTrait;
+  use DependencySerializationTrait;
+
+  protected const MALFORMED_JSON_EXCEPTION_CODE = 1000;
+
+  /**
+   * This module's configuration.
+   *
+   * @var \Drupal\Core\Config\ImmutableConfig
+   */
+  protected $config;
+
+  /**
+   * The http client.
+   *
+   * @var \GuzzleHttp\Client
+   */
+  protected $httpClient;
+
+  /**
+   * The cache backend.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $cache;
+
+  /**
+   * The time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * The logger.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * The update manager.
+   *
+   * @var \Drupal\update\UpdateManagerInterface
+   */
+  protected $updateManager;
+
+  /**
+   * Constructs a new UpdatesPsa object.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
+   *   The cache backend.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   * @param \GuzzleHttp\Client $client
+   *   The HTTP client.
+   * @param \Drupal\update\UpdateManagerInterface $update_manager
+   *   The update manager.
+   * @param \Psr\Log\LoggerInterface $logger
+   *   The logger.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, CacheBackendInterface $cache, TimeInterface $time, Client $client, UpdateManagerInterface $update_manager, LoggerInterface $logger) {
+    $this->config = $config_factory->get('update.settings');
+    $this->cache = $cache;
+    $this->time = $time;
+    $this->httpClient = $client;
+    $this->updateManager = $update_manager;
+    $this->logger = $logger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPublicServiceMessages() : array {
+    $messages = [];
+
+    if ($cache = $this->cache->get('updates_psa')) {
+      $response = $cache->data;
+    }
+    else {
+      $psa_endpoint = $this->config->get('psa.endpoint');
+      try {
+        $response = $this->httpClient->get($psa_endpoint)
+          ->getBody()
+          ->getContents();
+        $this->cache->set('updates_psa', $response, $this->time->getCurrentTime() + $this->config->get('psa.check_frequency'));
+      }
+      catch (TransferException $exception) {
+        $this->logger->error($exception->getMessage());
+        throw $exception;
+      }
+    }
+
+    $json_payload = json_decode($response, TRUE);
+    if ($json_payload !== NULL) {
+      foreach ($json_payload as $json) {
+        try {
+          $sa = SecurityAnnouncement::createFromArray($json);
+        }
+        catch (\UnexpectedValueException $unexpected_value_exception) {
+          $this->logger->error('PSA malformed: ' . $unexpected_value_exception->getMessage());
+          throw new \UnexpectedValueException($unexpected_value_exception->getMessage(), static::MALFORMED_JSON_EXCEPTION_CODE);
+        }
+
+        if ($sa->getProjectType() !== 'core' && !$this->isValidProject($sa->getProject())) {
+          continue;
+        }
+        if ($sa->isPsa() || $this->matchesInstalledVersion($sa)) {
+          $messages[] = $this->message($sa);
+        }
+      }
+    }
+    else {
+      $this->logger->error('Drupal PSA JSON is malformed: @response', ['@response' => $response]);
+      throw new \UnexpectedValueException('Drupal PSA JSON is malformed.', static::MALFORMED_JSON_EXCEPTION_CODE);
+    }
+
+    return $messages;
+  }
+
+  /**
+   * Gets a message from an exception thrown by ::getPublicServiceMessages().
+   *
+   * @param \Exception $exception
+   *   The exception throw by ::getPublicServiceMessages().
+   * @param bool $throw_unexpected_exceptions
+   *   Whether to re-throw exceptions that are not expected.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup|string
+   *   The message to display.
+   *
+   * @throws \Exception
+   *    Throw if the exception is not expected.
+   */
+  public static function getErrorMessageFromException(\Exception $exception, bool $throw_unexpected_exceptions = TRUE) {
+    if ($exception instanceof TransferException) {
+      return t(
+        'Unable to retrieve PSA information from :url.',
+        [':url' => \Drupal::config('update.settings')->get('psa.endpoint')]
+      );
+    }
+    elseif (get_class($exception) === \UnexpectedValueException::class && $exception->getCode() === static::MALFORMED_JSON_EXCEPTION_CODE) {
+      return t('Drupal PSA JSON is malformed.');
+    }
+    if ($throw_unexpected_exceptions) {
+      throw $exception;
+    }
+    return $exception->getMessage();
+  }
+
+  /**
+   * Determines if projects exists and has a version string.
+   *
+   * @param string $project_name
+   *   The project.
+   *
+   * @return bool
+   *   TRUE if project exists, otherwise FALSE.
+   */
+  protected function isValidProject(string $project_name) : bool {
+    try {
+      $project = $this->getProject($project_name);
+      return !empty($project['info']['version']);
+    }
+    catch (\UnexpectedValueException $exception) {
+      $this->logger->error($exception->getMessage());
+      return FALSE;
+    }
+  }
+
+  /**
+   * Determines if the Psa versions match for the installed version of project.
+   *
+   * @param \Drupal\update\Psa\SecurityAnnouncement $sa
+   *   The security announcement.
+   *
+   * @return bool
+   *   TRUE if security announcement matches the installed version of the
+   *   project, otherwise FALSE.
+   *
+   * @throws \UnexpectedValueException
+   *   Thrown by \Composer\Semver\VersionParser::parseConstraints() if the
+   *   constraint string is not valid.
+   */
+  protected function matchesInstalledVersion(SecurityAnnouncement $sa) : bool {
+    $parser = new VersionParser();
+    $versions = $sa->getProjectType() === 'core' ? $sa->getInsecureVersions() : $this->getContribVersions($sa->getInsecureVersions());
+
+    try {
+      $installed_constraint = $parser->parseConstraints($this->getInstalledVersion($sa));
+    }
+    catch (\UnexpectedValueException $exception) {
+      // If the installed version can not be parsed assume it matches to avoid
+      // not returning a critical PSA.
+      return TRUE;
+    }
+
+    foreach ($versions as $version) {
+      try {
+        if ($parser->parseConstraints($version)->matches($installed_constraint)) {
+          return TRUE;
+        }
+      }
+      catch (\UnexpectedValueException $exception) {
+        // If an individual constraint is throws an exception continue to check
+        // the other versions.
+        continue;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Returns a message that links the security announcement.
+   *
+   * @param \Drupal\update\Psa\SecurityAnnouncement $sa
+   *   The security announcement.
+   *
+   * @return \Drupal\Component\Render\FormattableMarkup
+   *   The PSA or SA message.
+   */
+  protected function message(SecurityAnnouncement $sa) : FormattableMarkup {
+    return new FormattableMarkup('<a href=":url">:message</a>', [
+      ':message' => $sa->getTitle(),
+      ':url' => $sa->getLink(),
+    ]);
+  }
+
+  /**
+   * Gets the contrib version to use for comparisons.
+   *
+   * @param string[] $versions
+   *   Contrib project versions.
+   *
+   * @return string[]
+   *   The versions that can be used for comparison.
+   */
+  private function getContribVersions(array $versions) : array {
+    $versions = array_filter(array_map(static function ($version) {
+      $version_array = explode('-', $version, 2);
+      if ($version_array && $version_array[0] === \Drupal::CORE_COMPATIBILITY) {
+        return isset($version_array[1]) ? $version_array[1] : NULL;
+      }
+      if (count($version_array) === 1) {
+        return $version_array[0];
+      }
+      if (count($version_array) === 2 && $version_array[1] === 'dev') {
+        return $version;
+      }
+    }, $versions));
+    return $versions;
+  }
+
+  /**
+   * Gets the currently installed version of a project.
+   *
+   * @param \Drupal\update\Psa\SecurityAnnouncement $sa
+   *   The security announcement.
+   *
+   * @return string
+   *   The currently installed version.
+   */
+  private function getInstalledVersion(SecurityAnnouncement $sa) : string {
+    $project = $this->getProject($sa->getProject());
+    $project_version = $project['info']['version'];
+    $version_array = explode('-', $project_version, 2);
+    return isset($version_array[1]) && $version_array[1] !== 'dev' ? $version_array[1] : $project_version;
+  }
+
+  /**
+   * Gets the project information.
+   *
+   * @param string $project_name
+   *   The project name.
+   *
+   * @return array
+   *   The project information if the project exists, otherwise an empty array.
+   */
+  protected function getProject(string $project_name): array {
+    static $projects = [];
+    if (empty($projects)) {
+      $projects = $this->updateManager->getProjects();
+    }
+    return $projects[$project_name] ?? [];
+  }
+
+}
diff --git a/core/modules/update/src/Psa/UpdatesPsaInterface.php b/core/modules/update/src/Psa/UpdatesPsaInterface.php
new file mode 100644
index 0000000000..27540a855a
--- /dev/null
+++ b/core/modules/update/src/Psa/UpdatesPsaInterface.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Drupal\update\Psa;
+
+/**
+ * Defines an interface to get Public Service Messages.
+ */
+interface UpdatesPsaInterface {
+
+  /**
+   * Gets public service messages.
+   *
+   * @return \Drupal\Component\Render\FormattableMarkup[]
+   *   A array of translatable strings.
+   */
+  public function getPublicServiceMessages() : array;
+
+}
diff --git a/core/modules/update/src/UpdateSettingsForm.php b/core/modules/update/src/UpdateSettingsForm.php
index f28359d7ce..b998fe1a4d 100644
--- a/core/modules/update/src/UpdateSettingsForm.php
+++ b/core/modules/update/src/UpdateSettingsForm.php
@@ -88,6 +88,27 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#description' => t('You can choose to send email only if a security update is available, or to be notified about all newer versions. If there are updates available of Drupal core or any of your installed modules and themes, your site will always print a message on the <a href=":status_report">status report</a> page, and will also display an error message on administration pages if there is a security update.', [':status_report' => Url::fromRoute('system.status')->toString()]),
     ];
 
+    $form['psa'] = [
+      '#type' => 'details',
+      '#title' => $this->t('Public service announcements'),
+      '#open' => TRUE,
+    ];
+    $form['psa']['description'] = [
+      '#markup' => '<p>' . $this->t('Public service announcements are compared against the entire code for the site, not just installed extensions.') . '</p>',
+    ];
+
+    $form['psa']['psa_enable'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Show Public service announcements on administrative pages.'),
+      '#default_value' => $config->get('psa.enable'),
+    ];
+    $form['psa']['psa_notify'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Send email notifications for Public service announcements.'),
+      '#default_value' => $config->get('psa.notify'),
+      '#description' => $this->t('The email addresses listed above will be notified.'),
+    ];
+
     return parent::buildForm($form, $form_state);
   }
 
@@ -120,6 +141,9 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
         $form_state->setErrorByName('update_notify_emails', $this->t('%emails are not valid email addresses.', ['%emails' => implode(', ', $invalid)]));
       }
     }
+    elseif ($form_state->getValue('psa_notify')) {
+      $form_state->setErrorByName('update_notify_emails',$this->t('If "Send email notifications for Public service announcements." is checked at least one email must be provided.'));
+    }
 
     parent::validateForm($form, $form_state);
   }
@@ -140,6 +164,8 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
       ->set('check.interval_days', $form_state->getValue('update_check_frequency'))
       ->set('notification.emails', $form_state->get('notify_emails'))
       ->set('notification.threshold', $form_state->getValue('update_notification_threshold'))
+      ->set('psa.enable', $form_state->getValue('psa_enable'))
+      ->set('psa.notify', $form_state->getValue('psa_notify'))
       ->save();
 
     parent::submitForm($form, $form_state);
diff --git a/core/modules/update/templates/updates-psa-notify.html.twig b/core/modules/update/templates/updates-psa-notify.html.twig
new file mode 100644
index 0000000000..b057b02201
--- /dev/null
+++ b/core/modules/update/templates/updates-psa-notify.html.twig
@@ -0,0 +1,38 @@
+{#
+/**
+ * @file
+ * Default theme implementation for the public service announcements email notification.
+ *
+ * Available variables:
+ * - messages: The messages array
+ *
+ * @ingroup themeable
+ */
+#}
+
+<p>
+  {% trans %}
+    A security update will be made available soon for your Drupal site. To ensure the security of the site, you should prepare the site to immediately apply the update once it is released!
+  {% endtrans %}
+</p>
+<p>
+  {% set status_report = path('system.status') %}
+  {% trans %}
+    See the <a href="{{ status_report }}">site status report page</a> for more information.
+  {% endtrans %}
+</p>
+<p>{{ 'Public service announcements:'|t }}</p>
+<ul>
+  {% for message in messages %}
+    <li>{{ message }}</li>
+  {% endfor %}
+</ul>
+<p>
+  {{ 'To see all PSAs, visit <a href="@uri">@uri</a>.'|t({'@uri': 'https://www.drupal.org/security/psa'}) }}
+</p>
+<p>
+  {% set settings_link = path('update.settings') %}
+  {% trans %}
+    Your site is currently configured to send these emails when a security update will be made available soon. To change how you are notified, you may <a href="{{ settings_link }}">configure email notifications</a>.
+  {% endtrans %}
+</p>
diff --git a/core/modules/update/tests/fixtures/psa_feed/invalid.json b/core/modules/update/tests/fixtures/psa_feed/invalid.json
new file mode 100644
index 0000000000..ab53a5c450
--- /dev/null
+++ b/core/modules/update/tests/fixtures/psa_feed/invalid.json
@@ -0,0 +1 @@
+[{"title":"You can't parse this! Oh no! 🔥🙀🐶
diff --git a/core/modules/update/tests/fixtures/psa_feed/valid.json b/core/modules/update/tests/fixtures/psa_feed/valid.json
new file mode 100644
index 0000000000..7c73814f60
--- /dev/null
+++ b/core/modules/update/tests/fixtures/psa_feed/valid.json
@@ -0,0 +1,85 @@
+[
+  {
+    "title":"Critical Release - SA-2019-02-19",
+    "link":"https:\/\/www.drupal.org\/sa-2019-02-19",
+    "project":"drupal",
+    "type":"core",
+    "insecure":[
+      "7.65",
+      "8.5.14",
+      "8.5.14",
+      "8.6.13",
+      "8.7.0-alpha2",
+      "8.7.0-beta1",
+      "8.7.0-beta2",
+      "8.6.14",
+      "8.6.15",
+      "8.6.15",
+      "8.5.15",
+      "8.5.15",
+      "7.66",
+      "8.7.0",
+      "9.11.0"
+    ],
+    "is_psa":"0",
+    "pubDate":"Tue, 19 Feb 2019 14:11:01 +0000"
+  },
+  {
+    "title":"Critical Release - PSA-Really Old",
+    "link":"https:\/\/www.drupal.org\/psa",
+    "project":"drupal",
+    "type":"core",
+    "is_psa":"1",
+    "insecure":[
+
+    ],
+    "pubDate":"Tue, 19 Feb 2017 14:11:01 +0000"
+  },
+  {
+    "title":"AAA Update Project - Moderately critical - Access bypass - SA-CONTRIB-2019",
+    "link":"https:\/\/www.drupal.org\/sa-contrib-2019",
+    "project":"aaa_update_project",
+    "type":"theme",
+    "is_psa":"0",
+    "insecure":[
+      "8.x-1.1",
+      "8.x-8.7.0"
+    ],
+    "pubDate":"Tue, 19 Mar 2019 12:50:00 +0000"
+  },
+  {
+    "title":"AAA Update Test - Moderately critical - Access bypass - SA-CONTRIB-2019",
+    "link":"https:\/\/www.drupal.org\/sa-contrib-2019",
+    "project":"aaa_update_test",
+    "type":"theme",
+    "is_psa":"0",
+    "insecure":[
+      "8.x-1.1",
+      "8.x-8.7.0"
+    ],
+    "pubDate":"Tue, 19 Mar 2019 12:50:00 +0000"
+  },
+  {
+    "title":"BBB Update project - Moderately critical - Access bypass - SA-CONTRIB-2019",
+    "link":"https:\/\/www.drupal.org\/sa-contrib-2019",
+    "project":"bbb_update_project",
+    "type":"module",
+    "is_psa":"1",
+    "insecure":[
+
+    ],
+    "pubDate":"Tue, 19 Mar 2019 12:50:00 +0000"
+  },
+  {
+    "title":"Missing Project - Moderately critical - Access bypass - SA-CONTRIB-2019",
+    "link":"https:\/\/www.drupal.org\/sa-contrib-2019",
+    "project":"missing_project",
+    "type":"module",
+    "is_psa":"1",
+    "insecure":[
+      "7.x-1.7",
+      "8.x-1.4"
+    ],
+    "pubDate":"Tue, 19 Mar 2019 12:50:00 +0000"
+  }
+]
diff --git a/core/modules/update/tests/fixtures/psa_feed/valid_plus1.json b/core/modules/update/tests/fixtures/psa_feed/valid_plus1.json
new file mode 100644
index 0000000000..9938469fcd
--- /dev/null
+++ b/core/modules/update/tests/fixtures/psa_feed/valid_plus1.json
@@ -0,0 +1,96 @@
+[
+  {
+    "title":"Critical Release - SA-2019-02-19",
+    "link":"https:\/\/www.drupal.org\/sa-2019-02-19",
+    "project":"drupal",
+    "type":"core",
+    "insecure":[
+      "7.65",
+      "8.5.14",
+      "8.5.14",
+      "8.6.13",
+      "8.7.0-alpha2",
+      "8.7.0-beta1",
+      "8.7.0-beta2",
+      "8.6.14",
+      "8.6.15",
+      "8.6.15",
+      "8.5.15",
+      "8.5.15",
+      "7.66",
+      "8.7.0",
+      "9.11.0"
+    ],
+    "is_psa":"0",
+    "pubDate":"Tue, 19 Feb 2019 14:11:01 +0000"
+  },
+  {
+    "title":"Critical Release - PSA-Really Old",
+    "link":"https:\/\/www.drupal.org\/psa",
+    "project":"drupal",
+    "type":"core",
+    "is_psa":"1",
+    "insecure":[
+
+    ],
+    "pubDate":"Tue, 19 Feb 2017 14:11:01 +0000"
+  },
+  {
+    "title":"AAA Update Project - Moderately critical - Access bypass - SA-CONTRIB-2019",
+    "link":"https:\/\/www.drupal.org\/sa-contrib-2019",
+    "project":"aaa_update_project",
+    "type":"theme",
+    "is_psa":"0",
+    "insecure":[
+      "8.x-1.1",
+      "8.x-8.7.0"
+    ],
+    "pubDate":"Tue, 19 Mar 2019 12:50:00 +0000"
+  },
+  {
+    "title":"AAA Update Test - Moderately critical - Access bypass - SA-CONTRIB-2019",
+    "link":"https:\/\/www.drupal.org\/sa-contrib-2019",
+    "project":"aaa_update_test",
+    "type":"theme",
+    "is_psa":"0",
+    "insecure":[
+      "8.x-1.1",
+      "8.x-8.7.0"
+    ],
+    "pubDate":"Tue, 19 Mar 2019 12:50:00 +0000"
+  },
+  {
+    "title":"BBB Update project - Moderately critical - Access bypass - SA-CONTRIB-2019",
+    "link":"https:\/\/www.drupal.org\/sa-contrib-2019",
+    "project":"bbb_update_project",
+    "type":"module",
+    "is_psa":"1",
+    "insecure":[
+
+    ],
+    "pubDate":"Tue, 19 Mar 2019 12:50:00 +0000"
+  },
+  {
+    "title":"Missing Project - Moderately critical - Access bypass - SA-CONTRIB-2019",
+    "link":"https:\/\/www.drupal.org\/sa-contrib-2019",
+    "project":"missing_project",
+    "type":"module",
+    "is_psa":"1",
+    "insecure":[
+      "7.x-1.7",
+      "8.x-1.4"
+    ],
+    "pubDate":"Tue, 19 Mar 2019 12:50:00 +0000"
+  },
+  {
+    "title":"Critical Release - PSA because 2020",
+    "link":"https:\/\/www.drupal.org\/psa",
+    "project":"drupal",
+    "type":"core",
+    "is_psa":"1",
+    "insecure":[
+
+    ],
+    "pubDate":"Tue, 19 Feb 2020 14:11:01 +0000"
+  }
+]
diff --git a/core/modules/update/tests/src/Functional/PsaTest.php b/core/modules/update/tests/src/Functional/PsaTest.php
new file mode 100644
index 0000000000..414ad804fd
--- /dev/null
+++ b/core/modules/update/tests/src/Functional/PsaTest.php
@@ -0,0 +1,280 @@
+<?php
+
+namespace Drupal\Tests\update\Functional;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\Test\AssertMailTrait;
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests of PSA functionality.
+ *
+ * @group update
+ */
+class PsaTest extends BrowserTestBase {
+
+  use AssertMailTrait;
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'update',
+    'aaa_update_test',
+    'update_test',
+  ];
+
+  /**
+   * A user with permission to administer site configuration and updates.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $user;
+
+  /**
+   * A working test PSA endpoint.
+   *
+   * @var string
+   */
+  protected $workingEndpoint;
+
+  /**
+   * A working test PSA endpoint that has 1 more item than $workingEndpoint.
+   *
+   * @var string
+   */
+  protected $workingEndpointPlus1;
+
+  /**
+   * A non-working test PSA endpoint.
+   *
+   * @var string
+   */
+  protected $nonWorkingEndpoint;
+
+  /**
+   * A test end PSA endpoint that returns invalid JSON.
+   *
+   * @var string
+   */
+  protected $invalidJsonEndpoint;
+
+  /**
+   * The cache service.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $cache;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() :void {
+    parent::setUp();
+    // Alter the 'aaa_update_test' to use the 'aaa_update_project' project name.
+    // The PSA feed will match project name and not extension name.
+    $system_info = [
+      '#all' => [
+        'version' => '9.11.0',
+      ],
+      'aaa_update_test' => [
+        'project' => 'aaa_update_project',
+        'version' => '8.x-1.1',
+        'hidden' => FALSE,
+      ],
+      'bbb_update_test' => [
+        'project' => 'bbb_update_project',
+        'version' => '8.x-1.1',
+        'hidden' => FALSE,
+      ],
+    ];
+    $this->config('update_test.settings')->set('system_info', $system_info)->save();
+    $this->user = $this->drupalCreateUser([
+      'access administration pages',
+      'administer site configuration',
+      'administer software updates',
+    ]);
+    $this->drupalLogin($this->user);
+    $fixtures_path = $this->baseUrl . '/core/modules/update/tests/fixtures/psa_feed';
+    $this->workingEndpoint = $this->buildUrl('/core/modules/update/tests/fixtures/psa_feed/valid.json');
+    $this->workingEndpointPlus1 = $this->buildUrl('/core/modules/update/tests/fixtures/psa_feed/valid_plus1.json');
+    $this->nonWorkingEndpoint = $this->buildUrl('/core/modules/update/tests/fixtures/psa_feed/missing.json');
+    $this->invalidJsonEndpoint = "$fixtures_path/invalid.json";
+
+    $this->cache = $this->container->get('cache.default');
+  }
+
+  /**
+   * Tests that a PSA is displayed.
+   */
+  public function testPsa() {
+    $assert = $this->assertSession();
+    // Setup test PSA endpoint.
+    $this->config('update.settings')
+      ->set('psa.endpoint', $this->workingEndpoint)
+      ->save();
+    $this->drupalGet(Url::fromRoute('system.admin'));
+    $assert->pageTextContains('Critical Release - SA-2019-02-19');
+    $assert->pageTextContains('Critical Release - PSA-Really Old');
+    $assert->pageTextContains('AAA Update Project - Moderately critical - Access bypass - SA-CONTRIB-2019');
+    $assert->pageTextNotContains('Node - Moderately critical - Access bypass - SA-CONTRIB-2019');
+    $assert->pageTextNotContains('Views - Moderately critical - Access bypass - SA-CONTRIB-2019');
+
+    // Test site status report.
+    $this->drupalGet(Url::fromRoute('system.status'));
+    $assert->pageTextContains('3 urgent announcements require your attention:');
+    $assert->pageTextContains('Critical Release - SA-2019-02-19');
+    $assert->pageTextContains('Critical Release - PSA-Really Old');
+    $assert->pageTextContains('AAA Update Project - Moderately critical - Access bypass - SA-CONTRIB-2019');
+
+    // Test cache.
+    $this->config('update.settings')
+      ->set('psa.endpoint', $this->nonWorkingEndpoint)
+      ->save();
+    $this->drupalGet(Url::fromRoute('system.admin'));
+    $assert->pageTextContains('Critical Release - SA-2019-02-19');
+    $assert->pageTextContains('Critical Release - PSA-Really Old');
+    $assert->pageTextNotContains('Node - Moderately critical - Access bypass - SA-CONTRIB-2019');
+    $assert->pageTextNotContains('Views - Moderately critical - Access bypass - SA-CONTRIB-2019');
+
+    // Test transmit errors with JSON endpoint.
+    drupal_flush_all_caches();
+    $this->drupalGet(Url::fromRoute('system.admin'));
+    $assert->pageTextContains('Unable to retrieve PSA information from ' . $this->nonWorkingEndpoint);
+    $assert->pageTextNotContains('Critical Release - SA-2019-02-19');
+
+    // Test disabling PSAs.
+    $this->config('update.settings')
+      ->set('psa.endpoint', $this->workingEndpoint)
+      ->save();
+    $this->setSettingsViaForm('psa_enable', FALSE);
+    drupal_flush_all_caches();
+    $this->drupalGet(Url::fromRoute('system.admin'));
+    $assert->pageTextNotContains('Critical Release - PSA-2019-02-19');
+    $this->drupalGet(Url::fromRoute('system.status'));
+    $assert->pageTextContains(' 3 urgent announcements require your attention');
+
+    // Test a PSA endpoint that returns invalid JSON.
+    $this->config('update.settings')
+      ->set('psa.endpoint', $this->invalidJsonEndpoint)
+      ->save();
+    $this->setSettingsViaForm('psa_enable', TRUE);
+    drupal_flush_all_caches();
+    $this->drupalGet(Url::fromRoute('system.admin'));
+    $assert->pageTextNotContains('Critical Release - PSA-2019-02-19');
+    $assert->pageTextContains('Drupal PSA JSON is malformed.');
+    $this->drupalGet(Url::fromRoute('system.status'));
+    $assert->pageTextContains('Drupal PSA JSON is malformed.');
+  }
+
+  /**
+   * Tests sending PSA email notifications.
+   */
+  public function testPsaMail() {
+    // Setup test PSA endpoint.
+    $this->config('update.settings')
+      ->set('psa.endpoint', $this->workingEndpoint)
+      ->save();
+    // Setup a default destination email address.
+    $this->config('update.settings')
+      ->set('notification.emails', ['admin@example.com'])
+      ->save();
+
+    // Confirm that PSA cache does not exist.
+    $this->assertFalse($this->cache->get('updates_psa'));
+
+    // Test PSAs on admin pages.
+    $this->drupalGet(Url::fromRoute('system.admin'));
+    $this->assertSession()->pageTextContains('Critical Release - SA-2019-02-19');
+    // Confirm that the PSA cache has been set.
+    $this->assertNotEmpty($this->cache->get('updates_psa'));
+
+    // Email should be sent.
+    $this->container->get('cron')->run();
+    $this->assertCount(1, $this->getPsaEmails());
+    $this->assertMailString('subject', '3 urgent Drupal announcements require your attention', 1);
+    $this->assertMailString('body', 'Critical Release - SA-2019-02-19', 1);
+
+    // Deleting the PSA cache will not result in another email if the messages
+    // have not changed.
+    $this->cache->delete('updates_psa');
+    $this->container->get('state')->set('system.test_mail_collector', []);
+    $this->container->get('cron')->run();
+    $this->assertCount(0, $this->getPsaEmails());
+
+    // Deleting the PSA cache will result in another email if the messages have
+    // changed.
+    $this->cache->delete('updates_psa');
+    $this->container->get('state')->set('system.test_mail_collector', []);
+    $this->config('update.settings')->set('psa.endpoint', $this->workingEndpointPlus1)->save();
+    $this->container->get('cron')->run();
+    $this->assertCount(1, $this->getPsaEmails());
+    $this->assertMailString('subject', '4 urgent Drupal announcements require your attention', 1);
+    $this->assertMailString('body', 'Critical Release - SA-2019-02-19', 1);
+    $this->assertMailString('body', 'Critical Release - PSA because 2020', 1);
+
+    // No email should be sent if PSA's are disabled even the endpoint has
+    // changed which will have different messages.
+    $this->cache->delete('updates_psa');
+    $this->container->get('state')->set('system.test_mail_collector', []);
+    // Do not include the extra item so the message would be different.
+    $this->config('update.settings')
+      ->set('psa.endpoint', $this->workingEndpoint)
+      ->save();
+    $this->setSettingsViaForm('psa_notify', FALSE);
+    $this->container->get('cron')->run();
+    $this->assertCount(0, $this->getPsaEmails());
+  }
+
+  /**
+   * Tests sending an email when the PSA JSON is invalid.
+   */
+  public function testInvalidJsonEmail() {
+    // Setup a default destination email address.
+    $this->config('update.settings')
+      ->set('notification.emails', ['admin@example.com'])
+      ->save();
+    $this->setSettingsViaForm('psa_notify', TRUE);
+    $this->config('update.settings')
+      ->set('psa.endpoint', $this->invalidJsonEndpoint)
+      ->save();
+    $this->cache->delete('updates_psa');
+    $this->container->get('cron')->run();
+    $this->assertCount(0, $this->getPsaEmails());
+  }
+
+  /**
+   * Sets a PSA setting via the settings form.
+   *
+   * @param string $checkbox
+   *   The name of the checkbox.
+   * @param bool $enable
+   *   Whether the setting should be enabled.
+   */
+  private function setSettingsViaForm(string $checkbox, bool $enable) {
+    $page = $this->getSession()->getPage();
+    $this->drupalGet('admin/reports/updates/settings');
+    if ($enable) {
+      $page->checkField($checkbox);
+    }
+    else {
+      $page->uncheckField($checkbox);
+    }
+    $page->pressButton('Save configuration');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPsaEmails() {
+    return $this->getMails(['id' => 'update_psa_notify']);
+  }
+
+}
diff --git a/core/modules/update/update.install b/core/modules/update/update.install
index 5c8da0da36..de5529ab6e 100644
--- a/core/modules/update/update.install
+++ b/core/modules/update/update.install
@@ -6,11 +6,14 @@
  */
 
 use Drupal\Core\Link;
+use Drupal\Core\StringTranslation\PluralTranslatableMarkup;
 use Drupal\Core\Url;
 use Drupal\update\ProjectSecurityData;
 use Drupal\update\ProjectSecurityRequirement;
+use Drupal\update\Psa\UpdatesPsa;
 use Drupal\update\UpdateFetcherInterface;
 use Drupal\update\UpdateManagerInterface;
+use GuzzleHttp\Exception\TransferException;
 
 /**
  * Implements hook_requirements().
@@ -68,6 +71,7 @@ function update_requirements($phase) {
       $requirements['update_core']['reason'] = UpdateFetcherInterface::UNKNOWN;
       $requirements['update_core']['description'] = _update_no_data();
     }
+    _update_psa_requirements($requirements);
   }
   return $requirements;
 }
@@ -177,3 +181,36 @@ function _update_requirement_check($project, $type) {
 function update_update_last_removed() {
   return 8001;
 }
+
+/**
+ * Display requirements from Public service announcements.
+ *
+ * @param array $requirements
+ *   The requirements array.
+ */
+function _update_psa_requirements(array &$requirements) {
+  $requirements['update_psa'] = [
+    'title' => t('Public service announcements'),
+    'severity' => REQUIREMENT_OK,
+    'value' => t('No announcements requiring attention.'),
+  ];
+  /** @var \Drupal\update\Psa\UpdatesPsaInterface $psa */
+  $psa = \Drupal::service('update.psa');
+  try {
+    $messages = $psa->getPublicServiceMessages();
+  }
+  catch (Exception $exception) {
+    $requirements['update_psa']['severity'] = REQUIREMENT_ERROR;
+    $requirements['update_psa']['value'] = UpdatesPsa::getErrorMessageFromException($exception);
+    return;
+  }
+
+  if (!empty($messages)) {
+    $requirements['update_psa']['severity'] = REQUIREMENT_ERROR;
+    $requirements['update_psa']['value'] = new PluralTranslatableMarkup(count($messages), '@count urgent announcement requires your attention:', '@count urgent announcements require your attention:');
+    $requirements['update_psa']['description'] = [
+      '#theme' => 'item_list',
+      '#items' => $messages,
+    ];
+  }
+}
diff --git a/core/modules/update/update.module b/core/modules/update/update.module
index d777a0669e..00a15cb7ef 100644
--- a/core/modules/update/update.module
+++ b/core/modules/update/update.module
@@ -17,8 +17,10 @@
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Site\Settings;
+use Drupal\update\Psa\UpdatesPsa;
 use Drupal\update\UpdateFetcherInterface;
 use Drupal\update\UpdateManagerInterface;
+use GuzzleHttp\Exception\TransferException;
 
 /**
  * Implements hook_help().
@@ -122,6 +124,23 @@ function update_page_top() {
         }
       }
     }
+
+    if (\Drupal::config('update.settings')->get('psa.enable')) {
+      /** @var \Drupal\update\Psa\UpdatesPsaInterface $psa */
+      $psa = \Drupal::service('update.psa');
+      try {
+        $messages = $psa->getPublicServiceMessages();
+        if ($messages) {
+          \Drupal::messenger()->addError(t('Public service announcements:'));
+          foreach ($messages as $message) {
+            \Drupal::messenger()->addError($message);
+          }
+        }
+      }
+      catch (Exception $exception) {
+        \Drupal::messenger()->addError(UpdatesPsa::getErrorMessageFromException($exception));
+      }
+    }
   }
 }
 
@@ -161,6 +180,11 @@ function update_theme() {
       'variables' => ['version' => NULL, 'title' => NULL, 'attributes' => []],
       'file' => 'update.report.inc',
     ],
+    'updates_psa_notify' => [
+      'variables' => [
+        'messages' => [],
+      ],
+    ],
   ];
 }
 
@@ -192,6 +216,10 @@ function update_cron() {
     _update_cron_notify();
   }
 
+  /** @var \Drupal\update\Psa\NotifyInterface $notify */
+  $notify = \Drupal::service('update.psa_notify');
+  $notify->send();
+
   // Clear garbage from disk.
   update_clear_update_disk_cache();
 }
@@ -386,7 +414,8 @@ function update_fetch_data_finished($success, $results) {
  * Constructs the email notification message when the site is out of date.
  *
  * @param $key
- *   Unique key to indicate what message to build, always 'status_notify'.
+ *   Unique key to indicate what message to build, either 'status_notify' or
+ *   'psa_notify'.
  * @param $message
  *   Reference to the message array being built.
  * @param $params
@@ -401,23 +430,32 @@ function update_fetch_data_finished($success, $results) {
  * @see \Drupal\update\UpdateManagerInterface
  */
 function update_mail($key, &$message, $params) {
-  $langcode = $message['langcode'];
-  $language = \Drupal::languageManager()->getLanguage($langcode);
-  $message['subject'] .= t('New release(s) available for @site_name', ['@site_name' => \Drupal::config('system.site')->get('name')], ['langcode' => $langcode]);
-  foreach ($params as $msg_type => $msg_reason) {
-    $message['body'][] = _update_message_text($msg_type, $msg_reason, $langcode);
-  }
-  $message['body'][] = t('See the available updates page for more information:', [], ['langcode' => $langcode]) . "\n" . Url::fromRoute('update.status', [], ['absolute' => TRUE, 'language' => $language])->toString();
-  if (_update_manager_access()) {
-    $message['body'][] = t('You can automatically install your missing updates using the Update manager:', [], ['langcode' => $langcode]) . "\n" . Url::fromRoute('update.report_update', [], ['absolute' => TRUE, 'language' => $language])->toString();
-  }
-  $settings_url = Url::fromRoute('update.settings', [], ['absolute' => TRUE])->toString();
-  if (\Drupal::config('update.settings')->get('notification.threshold') == 'all') {
-    $message['body'][] = t('Your site is currently configured to send these emails when any updates are available. To get notified only for security updates, @url.', ['@url' => $settings_url]);
+  if ($key === 'psa_notify') {
+    $renderer = \Drupal::service('renderer');
+
+    $message['subject'] = $params['subject'];
+    $message['body'][] = $renderer->render($params['body']);
   }
   else {
-    $message['body'][] = t('Your site is currently configured to send these emails only when security updates are available. To get notified for any available updates, @url.', ['@url' => $settings_url]);
+    $langcode = $message['langcode'];
+    $language = \Drupal::languageManager()->getLanguage($langcode);
+    $message['subject'] .= t('New release(s) available for @site_name', ['@site_name' => \Drupal::config('system.site')->get('name')], ['langcode' => $langcode]);
+    foreach ($params as $msg_type => $msg_reason) {
+      $message['body'][] = _update_message_text($msg_type, $msg_reason, $langcode);
+    }
+    $message['body'][] = t('See the available updates page for more information:', [], ['langcode' => $langcode]) . "\n" . Url::fromRoute('update.status', [], ['absolute' => TRUE, 'language' => $language])->toString();
+    if (_update_manager_access()) {
+      $message['body'][] = t('You can automatically install your missing updates using the Update manager:', [], ['langcode' => $langcode]) . "\n" . Url::fromRoute('update.report_update', [], ['absolute' => TRUE, 'language' => $language])->toString();
+    }
+    $settings_url = Url::fromRoute('update.settings', [], ['absolute' => TRUE])->toString();
+    if (\Drupal::config('update.settings')->get('notification.threshold') == 'all') {
+      $message['body'][] = t('Your site is currently configured to send these emails when any updates are available. To get notified only for security updates, @url.', ['@url' => $settings_url]);
+    }
+    else {
+      $message['body'][] = t('Your site is currently configured to send these emails only when security updates are available. To get notified for any available updates, @url.', ['@url' => $settings_url]);
+    }
   }
+
 }
 
 /**
diff --git a/core/modules/update/update.services.yml b/core/modules/update/update.services.yml
index 197e3d5f1d..675894d97d 100644
--- a/core/modules/update/update.services.yml
+++ b/core/modules/update/update.services.yml
@@ -22,3 +22,27 @@ services:
     class: Drupal\update\UpdateRootFactory
     arguments: ['@kernel', '@request_stack']
     public: false
+  logger.channel.updates:
+    parent: logger.channel_base
+    arguments: ['updates']
+  update.psa:
+    class: Drupal\update\Psa\UpdatesPsa
+    arguments:
+      - '@config.factory'
+      - '@cache.default'
+      - '@datetime.time'
+      - '@http_client'
+      - '@update.manager'
+      - '@logger.channel.updates'
+  update.psa_notify:
+    class: Drupal\update\Psa\EmailNotify
+    arguments:
+      - '@plugin.manager.mail'
+      - '@update.psa'
+      - '@config.factory'
+      - '@language_manager'
+      - '@state'
+      - '@datetime.time'
+      - '@entity_type.manager'
+      - '@string_translation'
+      - '@logger.channel.updates'
diff --git a/core/themes/stable/templates/admin/updates-psa-notify.html.twig b/core/themes/stable/templates/admin/updates-psa-notify.html.twig
new file mode 100644
index 0000000000..17c8579590
--- /dev/null
+++ b/core/themes/stable/templates/admin/updates-psa-notify.html.twig
@@ -0,0 +1,38 @@
+{#
+/**
+ * @file
+ * Theme override for the public service announcements email notification.
+ *
+ * Available variables:
+ * - messages: The messages array
+ *
+ * @ingroup themeable
+ */
+#}
+
+<p>
+  {% trans %}
+    A security update will be made available soon for your Drupal site. To ensure the security of the site, you should prepare the site to immediately apply the update once it is released!
+  {% endtrans %}
+</p>
+<p>
+  {% set status_report = path('system.status') %}
+  {% trans %}
+    See the <a href="{{ status_report }}">site status report page</a> for more information.
+  {% endtrans %}
+</p>
+<p>{{ 'Public service announcements:'|t }}</p>
+<ul>
+  {% for message in messages %}
+    <li>{{ message }}</li>
+  {% endfor %}
+</ul>
+<p>
+  {{ 'To see all PSAs, visit <a href="@uri">@uri</a>.'|t({'@uri': 'https://www.drupal.org/security/psa'}) }}
+</p>
+<p>
+  {% set settings_link = path('update.settings') %}
+  {% trans %}
+    Your site is currently configured to send these emails when a security update will be made available soon. To change how you are notified, you may <a href="{{ settings_link }}">configure email notifications</a>.
+  {% endtrans %}
+</p>
diff --git a/core/themes/stable9/templates/admin/updates-psa-notify.html.twig b/core/themes/stable9/templates/admin/updates-psa-notify.html.twig
new file mode 100644
index 0000000000..17c8579590
--- /dev/null
+++ b/core/themes/stable9/templates/admin/updates-psa-notify.html.twig
@@ -0,0 +1,38 @@
+{#
+/**
+ * @file
+ * Theme override for the public service announcements email notification.
+ *
+ * Available variables:
+ * - messages: The messages array
+ *
+ * @ingroup themeable
+ */
+#}
+
+<p>
+  {% trans %}
+    A security update will be made available soon for your Drupal site. To ensure the security of the site, you should prepare the site to immediately apply the update once it is released!
+  {% endtrans %}
+</p>
+<p>
+  {% set status_report = path('system.status') %}
+  {% trans %}
+    See the <a href="{{ status_report }}">site status report page</a> for more information.
+  {% endtrans %}
+</p>
+<p>{{ 'Public service announcements:'|t }}</p>
+<ul>
+  {% for message in messages %}
+    <li>{{ message }}</li>
+  {% endfor %}
+</ul>
+<p>
+  {{ 'To see all PSAs, visit <a href="@uri">@uri</a>.'|t({'@uri': 'https://www.drupal.org/security/psa'}) }}
+</p>
+<p>
+  {% set settings_link = path('update.settings') %}
+  {% trans %}
+    Your site is currently configured to send these emails when a security update will be made available soon. To change how you are notified, you may <a href="{{ settings_link }}">configure email notifications</a>.
+  {% endtrans %}
+</p>
