diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 76480ea1c12d7399a77b635e8d391f8c06cec1d7..f1f574c642483842c95dbbae41a283e22aa429f0 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -293,6 +293,7 @@ export default {
           :key="`${currentKey}-${field.name}`"
           v-bind="field"
           :is-validated="isValidated"
+          :data-qa-selector="`${field.name}_div`"
         />
       </div>
     </div>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
index 9e1ad24ae9fc62f4e88b94d8f2b516e9d192723e..b8fd8995744104b4526c8866c36895b882c7fa0a 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
@@ -33,6 +33,7 @@ export default {
       :key="`${currentKey}-${field.name}`"
       v-bind="field"
       :is-validated="isValidated"
+      :data-qa-selector="`${field.name}_div`"
     />
   </div>
 </template>
diff --git a/db/post_migrate/20220630050050_index_vulnerability_reads_on_casted_cluster_agent_id_full.rb b/db/post_migrate/20220630050050_index_vulnerability_reads_on_casted_cluster_agent_id_full.rb
new file mode 100644
index 0000000000000000000000000000000000000000..58b6342e30f5b3cd6f2147f5e6659bb5990e7fd2
--- /dev/null
+++ b/db/post_migrate/20220630050050_index_vulnerability_reads_on_casted_cluster_agent_id_full.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class IndexVulnerabilityReadsOnCastedClusterAgentIdFull < Gitlab::Database::Migration[2.0]
+  disable_ddl_transaction!
+
+  INDEX_NAME = 'index_vuln_reads_on_casted_cluster_agent_id_where_it_is_null'
+
+  def up
+    add_concurrent_index :vulnerability_reads,
+      :casted_cluster_agent_id,
+      name: INDEX_NAME,
+      where: 'casted_cluster_agent_id IS NOT NULL'
+  end
+
+  def down
+    remove_concurrent_index_by_name :vulnerability_reads, INDEX_NAME
+  end
+end
diff --git a/db/schema_migrations/20220630050050 b/db/schema_migrations/20220630050050
new file mode 100644
index 0000000000000000000000000000000000000000..2ec998847eb05307e8956973f6999e9edf93e168
--- /dev/null
+++ b/db/schema_migrations/20220630050050
@@ -0,0 +1 @@
+dfb314ef76efc54a2464e6b84e71753caf58bc8508f9e64b403066ea4847fe56
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index f39b7270eed37e59b63c04ace38a07fe13f8a09e..097d49d4bb0839b22c67d4466101b7c2707d60a5 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -29912,6 +29912,8 @@ COMMENT ON INDEX index_verification_codes_on_phone_and_visitor_id_code IS 'JiHu-
 
 CREATE UNIQUE INDEX index_vuln_historical_statistics_on_project_id_and_date ON vulnerability_historical_statistics USING btree (project_id, date);
 
+CREATE INDEX index_vuln_reads_on_casted_cluster_agent_id_where_it_is_null ON vulnerability_reads USING btree (casted_cluster_agent_id) WHERE (casted_cluster_agent_id IS NOT NULL);
+
 CREATE INDEX index_vuln_reads_on_project_id_state_severity_and_vuln_id ON vulnerability_reads USING btree (project_id, state, severity, vulnerability_id DESC);
 
 CREATE INDEX index_vulnerabilites_common_finder_query ON vulnerabilities USING btree (project_id, state, report_type, severity, id);
diff --git a/ee/app/services/geo/container_repository_sync.rb b/ee/app/services/geo/container_repository_sync.rb
index 5e78203b66aa7e5f3c87c268d616b6a42c8bb80d..88b6cf480e25622b38193766a19f602242bb2559 100644
--- a/ee/app/services/geo/container_repository_sync.rb
+++ b/ee/app/services/geo/container_repository_sync.rb
@@ -41,30 +41,48 @@ def sync_tag(tag)
 
       case manifest_parsed['mediaType']
       when *ATOMIC_MANIFESTS
-        push_manifest_blobs(manifest_parsed)
+        sync_manifest_blobs(manifest_parsed)
       when *LIST_MANIFESTS
-        manifest_parsed['manifests'].each do |submanifest|
-          image_info_raw = client.repository_raw_manifest(repository_path, submanifest['digest'])
-          image_info = Gitlab::Json.parse(image_info_raw)
-          push_manifest_blobs(image_info)
-          container_repository.push_manifest(submanifest['digest'], image_info_raw, image_info['mediaType'])
+        if buildkit_oci_incompatible_index?(manifest_parsed['manifests'])
+          sync_manifest_blobs(manifest_parsed)
+        else
+          manifest_parsed['manifests'].each do |submanifest|
+            image_info_raw = client.repository_raw_manifest(repository_path, submanifest['digest'])
+            image_info = Gitlab::Json.parse(image_info_raw)
+            sync_manifest_blobs(image_info)
+            container_repository.push_manifest(submanifest['digest'], image_info_raw, image_info['mediaType'])
+          end
         end
       else
         raise "Unexpected mediaType: #{manifest_parsed['mediaType']}"
       end
+
       container_repository.push_manifest(tag[:name], manifest, manifest_parsed['mediaType'])
     end
 
-    def push_manifest_blobs(manifest)
+    # Buildkit-cache images have special oci-spec-invalid structure where fat manifests reference
+    # blobs directly. Normal OCI fat manifest only references other manifests
+    # Issue https://github.com/moby/buildkit/issues/2251
+    def buildkit_oci_incompatible_index?(manifests)
+      manifests.any? do |manifest|
+        manifest['mediaType'].include?('application/vnd.buildkit.cacheconfig')
+      end
+    end
+
+    def sync_manifest_blobs(manifest)
       list_blobs(manifest).each do |digest|
-        next if container_repository.blob_exists?(digest)
+        sync_blob(digest)
+      end
+    end
 
-        file = client.pull_blob(repository_path, digest)
-        begin
-          container_repository.push_blob(digest, file.path)
-        ensure
-          file.unlink
-        end
+    def sync_blob(digest)
+      return if container_repository.blob_exists?(digest)
+
+      file = client.pull_blob(repository_path, digest)
+      begin
+        container_repository.push_blob(digest, file.path)
+      ensure
+        file.unlink
       end
     end
 
@@ -72,8 +90,11 @@ def remove_tag(tag)
       container_repository.delete_tag_by_digest(tag[:digest])
     end
 
+    # Normally "layers" is the only reference to blobs related to particular manifest
+    # This method is also used for build cache images where fat manifest references blobs directly
+    # in this case "manifests" attribute is a way of refering to them.
     def list_blobs(manifest)
-      layers = manifest['layers'].filter_map do |layer|
+      layers = (manifest['layers'] || manifest['manifests']).filter_map do |layer|
         layer['digest'] unless foreign_layer?(layer)
       end
 
diff --git a/ee/spec/services/geo/container_repository_sync_spec.rb b/ee/spec/services/geo/container_repository_sync_spec.rb
index fdf328f707985a742fe6bed04595458f5c6161eb..2dc75dbb25c7f2d4800dea1bc8ff1ef56081eb73 100644
--- a/ee/spec/services/geo/container_repository_sync_spec.rb
+++ b/ee/spec/services/geo/container_repository_sync_spec.rb
@@ -5,10 +5,10 @@
 RSpec.describe Geo::ContainerRepositorySync, :geo do
   let_it_be(:group) { create(:group, name: 'group') }
   let_it_be(:project) { create(:project, path: 'test', group: group) }
-  let_it_be(:container_repository) { create(:container_repository, name: 'my_image', project: project) }
 
+  let(:container_repository) { create(:container_repository, name: 'my_image', project: project) }
   let(:primary_api_url) { 'http://primary.registry.gitlab' }
-  let(:secondary_api_url) { 'http://registry.gitlab' }
+  let(:secondary_api_url) { 'http://secondary.registry.gitlab' }
   let(:primary_repository_url) { "#{primary_api_url}/v2/#{container_repository.path}" }
   let(:secondary_repository_url ) { "#{secondary_api_url}/v2/#{container_repository.path}" }
 
@@ -26,20 +26,6 @@
     "}"
   end
 
-  let(:oci_manifest) do
-    %Q(
-      {
-        "schemaVersion":2,
-        "mediaType":"application/vnd.oci.image.manifest.v1+json",
-        "layers":[
-          {"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":3333,"digest":"sha256:3333"},
-          {"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":4444,"digest":"sha256:4444"},
-          {"mediaType":"application/vnd.docker.image.rootfs.foreign.diff.tar.gzip","size":5555,"digest":"sha256:5555","urls":["https://foo.bar/v2/zoo/blobs/sha256:5555"]}
-        ]
-     }
-    )
-  end
-
   let(:manifest_list) do
     %Q(
       {
@@ -60,45 +46,12 @@
     )
   end
 
-  let(:oci_manifest_list) do
-    %Q(
-      {
-        "schemaVersion":2,
-        "mediaType":"application/vnd.oci.image.index.v1+json",
-        "manifests":[
-          {
-            "mediaType":"application/vnd.oci.image.manifest.v1+json",
-            "size":6666,
-            "digest":"sha256:6666",
-            "platform":
-              {
-                "architecture":"arm64","os":"linux"
-              }
-          }
-        ]
-      }
-    )
-  end
-
   before do
     stub_container_registry_config(enabled: true, api_url: secondary_api_url)
     stub_registry_replication_config(enabled: true, primary_api_url: primary_api_url)
   end
 
-  def stub_primary_repository_tags_requests(repository_url, tags)
-    stub_request(:get, "#{repository_url}/tags/list")
-      .to_return(
-        status: 200,
-        body: Gitlab::Json.dump(tags: tags.keys),
-        headers: { 'Content-Type' => 'application/json' })
-
-    tags.each do |tag, digest|
-      stub_request(:head, "#{repository_url}/manifests/#{tag}")
-        .to_return(status: 200, body: "", headers: { DependencyProxy::Manifest::DIGEST_HEADER => digest })
-    end
-  end
-
-  def stub_secondary_repository_tags_requests(repository_url, tags)
+  def stub_repository_tags_requests(repository_url, tags)
     stub_request(:get, "#{repository_url}/tags/list")
       .to_return(
         status: 200,
@@ -111,22 +64,17 @@ def stub_secondary_repository_tags_requests(repository_url, tags)
     end
   end
 
-  def stub_primary_raw_manifest_request(repository_url, tag, manifest)
+  def stub_raw_manifest_request(repository_url, tag, manifest)
     stub_request(:get, "#{repository_url}/manifests/#{tag}")
       .to_return(status: 200, body: manifest, headers: {})
   end
 
-  def stub_secondary_raw_manifest_request(repository_url, tag, manifest)
-    stub_request(:get, "#{repository_url}/manifests/#{tag}")
-      .to_return(status: 200, body: manifest, headers: {})
-  end
-
-  def stub_primary_raw_manifest_list_request(repository_url, tag, manifest)
+  def stub_raw_manifest_list_request(repository_url, tag, manifest_list)
     stub_request(:get, "#{repository_url}/manifests/#{tag}")
       .to_return(status: 200, body: manifest_list, headers: {})
   end
 
-  def stub_secondary_push_manifest_request(repository_url, tag, manifest)
+  def stub_push_manifest_request(repository_url, tag, manifest)
     stub_request(:put, "#{repository_url}/manifests/#{tag}")
       .with(body: manifest)
       .to_return(status: 200, body: "", headers: {})
@@ -149,11 +97,11 @@ def stub_missing_blobs_requests(primary_repository_url, secondary_repository_url
 
     context 'single manifest' do
       it 'determines list of tags to sync and to remove correctly' do
-        stub_primary_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' })
-        stub_secondary_repository_tags_requests(secondary_repository_url, { 'tag-to-remove' => 'sha256:2222' })
-        stub_primary_raw_manifest_request(primary_repository_url, 'tag-to-sync', manifest)
+        stub_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' })
+        stub_repository_tags_requests(secondary_repository_url, { 'tag-to-remove' => 'sha256:2222' })
+        stub_raw_manifest_request(primary_repository_url, 'tag-to-sync', manifest)
         stub_missing_blobs_requests(primary_repository_url, secondary_repository_url, { 'sha256:3333' => true, 'sha256:4444' => false })
-        stub_secondary_push_manifest_request(secondary_repository_url, 'tag-to-sync', manifest)
+        stub_push_manifest_request(secondary_repository_url, 'tag-to-sync', manifest)
 
         expect(container_repository).to receive(:push_blob).with('sha256:3333', anything)
         expect(container_repository).not_to receive(:push_blob).with('sha256:4444', anything)
@@ -165,8 +113,8 @@ def stub_missing_blobs_requests(primary_repository_url, secondary_repository_url
 
       context 'when primary repository has no tags' do
         it 'removes secondary tags and does not fail' do
-          stub_primary_repository_tags_requests(primary_repository_url, {})
-          stub_secondary_repository_tags_requests(secondary_repository_url, { 'tag-to-remove' => 'sha256:2222' })
+          stub_repository_tags_requests(primary_repository_url, {})
+          stub_repository_tags_requests(secondary_repository_url, { 'tag-to-remove' => 'sha256:2222' })
 
           expect(container_repository).to receive(:delete_tag_by_digest).with('sha256:2222')
 
@@ -177,35 +125,104 @@ def stub_missing_blobs_requests(primary_repository_url, secondary_repository_url
 
     context 'manifest list' do
       it 'pushes the correct blobs and manifests' do
-        stub_primary_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' })
-        stub_secondary_repository_tags_requests(secondary_repository_url, {})
-        stub_primary_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', manifest_list)
-        stub_primary_raw_manifest_request(primary_repository_url, 'sha256:6666', manifest)
-        stub_secondary_raw_manifest_request(secondary_repository_url, 'sha256:6666', manifest)
+        stub_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' })
+        stub_repository_tags_requests(secondary_repository_url, {})
+        stub_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', manifest_list)
+        stub_raw_manifest_request(primary_repository_url, 'sha256:6666', manifest)
         stub_missing_blobs_requests(primary_repository_url, secondary_repository_url, { 'sha256:3333' => true, 'sha256:4444' => false })
 
         expect(container_repository).to receive(:push_blob).with('sha256:3333', anything)
         expect(container_repository).to receive(:push_manifest).with('sha256:6666', anything, anything)
         expect(container_repository).to receive(:push_manifest).with('tag-to-sync', anything, anything)
-        expect(container_repository).to receive(:delete_tag_by_digest).with('sha256:2222')
 
         subject.execute
       end
     end
 
     context 'oci manifest list' do
+      let(:oci_manifest) do
+        %Q(
+          {
+            "schemaVersion":2,
+            "mediaType":"application/vnd.oci.image.manifest.v1+json",
+            "layers":[
+              {"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":3333,"digest":"sha256:3333"},
+              {"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":4444,"digest":"sha256:4444"},
+              {"mediaType":"application/vnd.docker.image.rootfs.foreign.diff.tar.gzip","size":5555,"digest":"sha256:5555","urls":["https://foo.bar/v2/zoo/blobs/sha256:5555"]}
+            ]
+         }
+        )
+      end
+
+      let(:oci_manifest_list) do
+        %Q(
+          {
+            "schemaVersion":2,
+            "mediaType":"application/vnd.oci.image.index.v1+json",
+            "manifests":[
+              {
+                "mediaType":"application/vnd.oci.image.manifest.v1+json",
+                "size":6666,
+                "digest":"sha256:6666",
+                "platform":
+                  {
+                    "architecture":"arm64","os":"linux"
+                  }
+              }
+            ]
+          }
+        )
+      end
+
       it 'pushes the correct blobs and manifests' do
-        stub_primary_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' })
-        stub_secondary_repository_tags_requests(secondary_repository_url, {})
-        stub_primary_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', oci_manifest_list)
-        stub_primary_raw_manifest_request(primary_repository_url, 'sha256:6666', oci_manifest)
-        stub_secondary_raw_manifest_request(secondary_repository_url, 'sha256:6666', manifest)
+        stub_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' })
+        stub_repository_tags_requests(secondary_repository_url, {})
+        stub_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', oci_manifest_list)
+        stub_raw_manifest_request(primary_repository_url, 'sha256:6666', oci_manifest)
         stub_missing_blobs_requests(primary_repository_url, secondary_repository_url, { 'sha256:3333' => true, 'sha256:4444' => false })
 
         expect(container_repository).to receive(:push_blob).with('sha256:3333', anything)
         expect(container_repository).to receive(:push_manifest).with('sha256:6666', anything, anything)
         expect(container_repository).to receive(:push_manifest).with('tag-to-sync', anything, anything)
-        expect(container_repository).to receive(:delete_tag_by_digest).with('sha256:2222')
+
+        subject.execute
+      end
+    end
+
+    context 'buildkit cache images' do
+      let(:buildcache_manifest_list) do
+        %Q(
+          {
+            "schemaVersion":2,
+            "mediaType":"application/vnd.oci.image.index.v1+json",
+            "manifests":[
+              {
+                "mediaType":"application/vnd.oci.image.layer.v1.tar+gzip",
+                "digest":"sha256:3333",
+                "size":24803024,
+                "annotations":{
+                   "buildkit/createdat":"2022-06-17T16:44:22.638028085Z",
+                   "containerd.io/uncompressed":"sha256:65feea9638f81cb1fab4ede714f970bb8453cd1a2aa23860d2bb3fdcb960068b"
+                }
+              },
+              {
+                "mediaType":"application/vnd.buildkit.cacheconfig.v0",
+                "digest":"sha256:4444",
+                "size":1753
+              }
+            ]
+          }
+        )
+      end
+
+      it 'pushes the correct blobs and manifests' do
+        stub_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' })
+        stub_repository_tags_requests(secondary_repository_url, {})
+        stub_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', buildcache_manifest_list)
+        stub_missing_blobs_requests(primary_repository_url, secondary_repository_url, { 'sha256:3333' => true, 'sha256:4444' => false })
+
+        expect(container_repository).to receive(:push_blob).with('sha256:3333', anything)
+        expect(container_repository).to receive(:push_manifest).with('tag-to-sync', anything, anything)
 
         subject.execute
       end
diff --git a/package.json b/package.json
index e33699b0402c63b54f98bce3c6385a4b4ea707ec..34027119cbc96be7be0e8d99eb91ecb77635c62e 100644
--- a/package.json
+++ b/package.json
@@ -170,7 +170,7 @@
     "sortablejs": "^1.10.2",
     "string-hash": "1.1.3",
     "style-loader": "^2.0.0",
-    "swagger-ui-dist": "4.8.0",
+    "swagger-ui-dist": "4.12.0",
     "three": "^0.84.0",
     "three-orbit-controls": "^82.1.0",
     "three-stl-loader": "^1.0.4",
diff --git a/qa/qa/page/project/settings/integrations.rb b/qa/qa/page/project/settings/integrations.rb
index 420dcb6391878220329e96938827302d66c06388..0d5515aacdf8bdcf4c577a39548a827c5bbea562 100644
--- a/qa/qa/page/project/settings/integrations.rb
+++ b/qa/qa/page/project/settings/integrations.rb
@@ -8,12 +8,17 @@ class Integrations < QA::Page::Base
           view 'app/assets/javascripts/integrations/index/components/integrations_table.vue' do
             element :prometheus_link, %q(:data-qa-selector="`${item.name}_link`") # rubocop:disable QA/ElementWithPattern
             element :jira_link, %q(:data-qa-selector="`${item.name}_link`") # rubocop:disable QA/ElementWithPattern
+            element :pipelines_email_link, %q(:data-qa-selector="`${item.name}_link`") # rubocop:disable QA/ElementWithPattern
           end
 
           def click_on_prometheus_integration
             click_element :prometheus_link
           end
 
+          def click_pipelines_email_link
+            click_element :pipelines_email_link
+          end
+
           def click_jira_link
             click_element :jira_link
           end
diff --git a/qa/qa/page/project/settings/services/pipeline_status_emails.rb b/qa/qa/page/project/settings/services/pipeline_status_emails.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2f78577e3d5fc415229296976b0bd474ee394fc5
--- /dev/null
+++ b/qa/qa/page/project/settings/services/pipeline_status_emails.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module QA
+  module Page
+    module Project
+      module Settings
+        module Services
+          class PipelineStatusEmails < QA::Page::Base
+            view 'app/assets/javascripts/integrations/edit/components/integration_form.vue' do
+              element :recipients_div, %q(:data-qa-selector="`${field.name}_div`") # rubocop:disable QA/ElementWithPattern
+              element :notify_only_broken_pipelines_div, %q(:data-qa-selector="`${field.name}_div`") # rubocop:disable QA/ElementWithPattern
+              element :save_changes_button
+            end
+
+            def set_recipients(emails)
+              within_element :recipients_div do
+                fill_in 'Recipients', with: emails.join(',')
+              end
+            end
+
+            def toggle_notify_broken_pipelines
+              within_element :notify_only_broken_pipelines_div do
+                uncheck 'Notify only broken pipelines', allow_label_click: true
+              end
+            end
+
+            def click_save_button
+              click_element(:save_changes_button)
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/qa/qa/resource/user.rb b/qa/qa/resource/user.rb
index 1b848feb50da3589658c5783a4fe0a203239095b..2fb8b18b71f41b5a02eff09fedbb7fbae1750e8d 100644
--- a/qa/qa/resource/user.rb
+++ b/qa/qa/resource/user.rb
@@ -104,12 +104,6 @@ def exists?
         false
       end
 
-      def api_delete
-        super
-
-        QA::Runtime::Logger.debug("Deleted user '#{username}'")
-      end
-
       def api_delete_path
         "/users/#{id}?hard_delete=#{hard_delete_on_api_removal}"
       rescue NoValueError
diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb
index 44cae31f5d81dc560369527aefd2b70f63850319..b1d59b90e9c2552b0e1b0503b7b09825ff2f35a1 100644
--- a/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb
+++ b/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb
@@ -2,49 +2,45 @@
 
 module QA
   RSpec.describe 'Manage' do
-    describe 'Check for broken images', :requires_admin do
-      before(:context) do
-        @api_client = Runtime::API::Client.as_admin
-        @new_user = Resource::User.fabricate_via_api! do |user|
-          user.api_client = @api_client
-        end
-        @new_admin = Resource::User.fabricate_via_api! do |user|
-          user.admin = true
-          user.api_client = @api_client
-        end
+    shared_examples 'loads all images' do |admin|
+      let(:api_client) { Runtime::API::Client.as_admin }
 
-        Page::Main::Menu.perform(&:sign_out_if_signed_in)
+      let(:user) do
+        Resource::User.fabricate_via_api! do |resource|
+          resource.admin = admin
+          resource.api_client = api_client
+        end
       end
 
-      after(:context) do
-        @new_user.remove_via_api!
-        @new_admin.remove_via_api!
+      after do
+        user.remove_via_api!
       end
 
-      shared_examples 'loads all images' do
-        it 'loads all images' do
-          Runtime::Browser.visit(:gitlab, Page::Main::Login)
-          Page::Main::Login.perform { |login| login.sign_in_using_credentials(user: new_user) }
+      it 'loads all images' do
+        Flow::Login.sign_in(as: user)
 
-          Page::Dashboard::Welcome.perform do |welcome|
-            expect(welcome).to have_welcome_title("Welcome to GitLab")
+        Page::Dashboard::Welcome.perform do |welcome|
+          expect(welcome).to have_welcome_title("Welcome to GitLab")
 
-            # This would be better if it were a visual validation test
-            expect(welcome).to have_loaded_all_images
-          end
+          # This would be better if it were a visual validation test
+          expect(welcome).to have_loaded_all_images
         end
       end
+    end
 
-      context 'when logged in as a new user', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347885' do
-        it_behaves_like 'loads all images' do
-          let(:new_user) { @new_user }
-        end
+    describe 'Check for broken images', :requires_admin, :reliable do
+      context(
+        'when logged in as a new user',
+        testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347885'
+      ) do
+        it_behaves_like 'loads all images', false
       end
 
-      context 'when logged in as a new admin', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347884' do
-        it_behaves_like 'loads all images' do
-          let(:new_user) { @new_admin }
-        end
+      context(
+        'when logged in as a new admin',
+        testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347884'
+      ) do
+        it_behaves_like 'loads all images', true
       end
     end
   end
diff --git a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb
index f41e59856226b3ddc6312b699b3df7161bbacd93..b815186cd493e2cbb7ad7a589e226ac65a7a68b3 100644
--- a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb
+++ b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb
@@ -46,19 +46,22 @@ def mailhog_json
           total = mailhog_data.dig('total')
           subjects = mailhog_data.dig('items')
             .map(&method(:mailhog_item_subject))
-            .join("\n")
 
           Runtime::Logger.debug(%Q[Total number of emails: #{total}])
-          Runtime::Logger.debug(%Q[Subjects:\n#{subjects}])
+          Runtime::Logger.debug(%Q[Subjects:\n#{subjects.join("\n")}])
 
           # Expect at least two invitation messages: group and project
-          mailhog_data if total >= 2
+          mailhog_data if mailhog_project_message_count(subjects) >= 1
         end
       end
 
       def mailhog_item_subject(item)
         item.dig('Content', 'Headers', 'Subject', 0)
       end
+
+      def mailhog_project_message_count(subjects)
+        subjects.count { |subject| subject.include?('project was granted') }
+      end
     end
   end
 end
diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_status_emails_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_status_emails_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f4794b3a904a39827ba0d27088e7860f560ad9af
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_status_emails_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+module QA
+  RSpec.shared_examples 'notifies on a pipeline' do |exit_code|
+    before do
+      push_commit(exit_code: exit_code)
+    end
+
+    it 'sends an email' do
+      meta = exit_code_meta(exit_code)
+
+      project.visit!
+      Flow::Pipeline.wait_for_latest_pipeline(status: meta[:status])
+
+      messages = mail_hog_messages(mail_hog)
+      subjects = messages.map(&:subject)
+      targets = messages.map(&:to)
+
+      aggregate_failures do
+        expect(subjects).to include(meta[:email_subject])
+        expect(subjects).to include(/#{Regexp.escape(project.name)}/)
+        expect(targets).to include(*emails)
+      end
+    end
+  end
+
+  RSpec.describe 'Verify', :orchestrated, :runner, :requires_admin, :smtp do
+    describe 'Pipeline status emails' do
+      let(:executor) { "qa-runner-#{Time.now.to_i}" }
+      let(:emails) { %w[foo@bar.com baz@buzz.com] }
+
+      let(:project) do
+        Resource::Project.fabricate_via_api! do |project|
+          project.name = 'pipeline-status-project'
+        end
+      end
+
+      let!(:runner) do
+        Resource::Runner.fabricate! do |runner|
+          runner.project = project
+          runner.name = executor
+          runner.tags = [executor]
+        end
+      end
+
+      let(:mail_hog) { Vendor::MailHog::API.new }
+
+      before(:all) do
+        Runtime::ApplicationSettings.set_application_settings(allow_local_requests_from_web_hooks_and_services: true)
+      end
+
+      before do
+        setup_pipeline_emails(emails)
+      end
+
+      describe 'when pipeline passes', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/366240' do
+        include_examples 'notifies on a pipeline', 0
+      end
+
+      describe 'when pipeline fails', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/366241' do
+        include_examples 'notifies on a pipeline', 1
+      end
+
+      def push_commit(exit_code: 0)
+        Resource::Repository::Commit.fabricate_via_api! do |commit|
+          commit.project = project
+          commit.commit_message = 'Add .gitlab-ci.yml'
+          commit.add_files(
+            [
+              {
+                file_path: '.gitlab-ci.yml',
+                content: gitlab_ci_yaml(exit_code: exit_code)
+              }
+            ]
+          )
+        end
+      end
+
+      def setup_pipeline_emails(emails)
+        page.visit Runtime::Scenario.gitlab_address
+        Flow::Login.sign_in_unless_signed_in
+
+        project.visit!
+
+        Page::Project::Menu.perform(&:go_to_integrations_settings)
+        QA::Page::Project::Settings::Integrations.perform(&:click_pipelines_email_link)
+
+        QA::Page::Project::Settings::Services::PipelineStatusEmails.perform do |pipeline_status_emails|
+          pipeline_status_emails.toggle_notify_broken_pipelines # notify on pass and fail
+          pipeline_status_emails.set_recipients(emails)
+          pipeline_status_emails.click_save_button
+        end
+      end
+
+      def gitlab_ci_yaml(exit_code: 0, tag: executor)
+        <<~YAML
+          test-pipeline-email:
+            tags:
+              - #{tag}
+            script: sleep 5; exit #{exit_code};
+        YAML
+      end
+
+      private
+
+      def exit_code_meta(exit_code)
+        {
+          0 => { status: 'passed', email_subject: /Successful pipeline/ },
+          1 => { status: 'failed', email_subject: /Failed pipeline/ }
+        }[exit_code]
+      end
+
+      def mail_hog_messages(mail_hog_api)
+        Support::Retrier.retry_until(sleep_interval: 1) do
+          Runtime::Logger.debug('Fetching email...')
+
+          messages = mail_hog_api.fetch_messages
+          logs = messages.map { |m| "#{m.to}: #{m.subject}" }
+
+          Runtime::Logger.debug("MailHog Logs: #{logs.join("\n")}")
+
+          # for failing pipelines we have three messages
+          # one for the owner
+          # and one for each recipient
+          messages if mail_hog_pipeline_count(messages) >= 2
+        end
+      end
+
+      def mail_hog_pipeline_count(messages)
+        messages.count { |message| message.subject.include?('pipeline') }
+      end
+    end
+  end
+end
diff --git a/qa/qa/vendor/mail_hog/api.rb b/qa/qa/vendor/mail_hog/api.rb
new file mode 100644
index 0000000000000000000000000000000000000000..85eb06316247c8a4ece6cc0ca1a41e70fe6837fe
--- /dev/null
+++ b/qa/qa/vendor/mail_hog/api.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module QA
+  module Vendor
+    module MailHog
+      # Represents a Set of messages from a MailHog response
+      class Messages
+        include Enumerable
+
+        attr_reader :data
+
+        def initialize(data)
+          @data = data
+        end
+
+        def total
+          data.dig('total')
+        end
+
+        def each
+          data.dig('items')&.each do |item|
+            yield MessageItem.new(item)
+          end
+        end
+      end
+
+      # Represents an email item from a MailHog response
+      class MessageItem
+        attr_reader :data
+
+        def initialize(data)
+          @data = data
+        end
+
+        def to
+          data.dig('Content', 'Headers', 'To', 0)
+        end
+
+        def subject
+          data.dig('Content', 'Headers', 'Subject', 0)
+        end
+      end
+
+      class API
+        include Support::API
+
+        attr_reader :hostname
+
+        def initialize(hostname: QA::Runtime::Env.mailhog_hostname || 'localhost')
+          @hostname = hostname
+        end
+
+        def base_url
+          "http://#{hostname}:8025"
+        end
+
+        def api_messages_url(version: 2)
+          "#{base_url}/api/v#{version}/messages"
+        end
+
+        def delete_messages
+          delete(api_messages_url(version: 1))
+        end
+
+        def fetch_messages
+          Messages.new(JSON.parse(fetch_messages_json))
+        end
+
+        def fetch_messages_json
+          get(api_messages_url).body
+        end
+      end
+    end
+  end
+end
diff --git a/yarn.lock b/yarn.lock
index e5b5cf166cac1ae63c20adadd6237a5f390d3a62..056c6ac2b0f14f1ee59432d12c23bf5cb1e20a44 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11951,10 +11951,10 @@ svg-tags@^1.0.0:
   resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
   integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=
 
-swagger-ui-dist@4.8.0:
-  version "4.8.0"
-  resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-4.8.0.tgz#5f39a038a02ffbd5defb8e1921a9ac1620d779ae"
-  integrity sha512-jdcO4XcbwkAtrwvHp90Usjx3d4JZMjaiS02CxBFfuSxr6G8DBXPcK471+N6BcBkwZK7VTgpUBFAyyarsAvKYFQ==
+swagger-ui-dist@4.12.0:
+  version "4.12.0"
+  resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-4.12.0.tgz#986d90f05e81fb9db3ca40372278a5d8ce71db3a"
+  integrity sha512-B0Iy2ueXtbByE6OOyHTi3lFQkpPi/L7kFOKFeKTr44za7dJIELa9kzaca6GkndCgpK1QTjArnoXG+aUy0XQp1w==
 
 symbol-observable@^1.0.4:
   version "1.2.0"