diff --git a/.gitlab/ci/setup.gitlab-ci.yml b/.gitlab/ci/setup.gitlab-ci.yml
index c3cc646ba5885f1a19a8bd7922aa96546fa30632..30c2e23c1ad25c501b56ba116812b346af6b39cd 100644
--- a/.gitlab/ci/setup.gitlab-ci.yml
+++ b/.gitlab/ci/setup.gitlab-ci.yml
@@ -66,7 +66,7 @@ set-pipeline-name:
     # We use > instead of | because we want the files to be space-separated.
     FILES_TO_DOWNLOAD: >
       scripts/utils.sh
-      scripts/pipeline/set-pipeline-name
+      scripts/pipeline/set_pipeline_name.rb
   image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION}-alpine3.16
   stage: prepare
   before_script:
@@ -75,7 +75,7 @@ set-pipeline-name:
   script:
     - source scripts/utils.sh
     - install_gitlab_gem
-    - chmod u+x scripts/pipeline/set-pipeline-name && scripts/pipeline/set-pipeline-name
+    - chmod u+x scripts/pipeline/set_pipeline_name.rb && scripts/pipeline/set_pipeline_name.rb
   allow_failure:
     exit_codes:
       - 3
diff --git a/scripts/pipeline/set-pipeline-name b/scripts/pipeline/set-pipeline-name
deleted file mode 100755
index 29d8949d51967b951519bce320740cdd28270fca..0000000000000000000000000000000000000000
--- a/scripts/pipeline/set-pipeline-name
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-require 'gitlab'
-
-class SetPipelineName
-  DOCS                   = ['docs-lint markdown', 'docs-lint links'].freeze
-  RSPEC_PREDICTIVE       = ['rspec:predictive:trigger', 'rspec-ee:predictive:trigger'].freeze
-  CODE                   = ['retrieve-tests-metadata'].freeze
-  QA_GDK                 = ['e2e:test-on-gdk'].freeze
-  REVIEW_APP             = ['start-review-app-pipeline'].freeze
-  #  TODO: Please remove `trigger-omnibus-and-follow-up-e2e` and `follow-up-e2e:package-and-test-ee`
-  #        after 2025-04-08 in this project
-  #
-  #   `trigger-omnibus-and-follow-up-e2e` was renamed to `follow-up:trigger-omnibus` on 2024-04-08 via
-  #    https://gitlab.com/gitlab-org/gitlab/-/merge_requests/147908/diffs?pin=c11467759d7eae77ed84e02a5445e21704c8d8e5#c11467759d7eae77ed84e02a5445e21704c8d8e5_105_104
-  #
-  #   `follow-up-e2e:package-and-test-ee` was renamed to `follow-up:e2e:package-and-test-ee` on 2024-04-08 via
-  #    https://gitlab.com/gitlab-org/gitlab/-/merge_requests/147908/diffs?pin=c11467759d7eae77ed84e02a5445e21704c8d8e5#c11467759d7eae77ed84e02a5445e21704c8d8e5_136_137
-  QA                     = [
-    'e2e:package-and-test-ce',
-    'e2e:package-and-test-ee',
-    'follow-up-e2e:package-and-test-ee',
-    'follow-up:e2e:package-and-test-ee',
-    'follow-up:trigger-omnibus',
-    'trigger-omnibus-and-follow-up-e2e'
-  ].freeze
-  # Ordered by expected duration, DESC
-  PIPELINE_TYPES_ORDERED = %w[qa review-app qa-gdk code rspec-predictive docs].freeze
-
-  def initialize
-    @pipeline_types = Set.new
-  end
-
-  def execute
-    if ENV['CI_PIPELINE_NAME'].match?(/\[types: .+\]/)
-      puts "Pipeline name '#{ENV['CI_PIPELINE_NAME']}' already has types in its name."
-      return
-    end
-
-    begin
-      Gitlab.pipeline_bridges(ENV['CI_PROJECT_ID'], ENV['CI_PIPELINE_ID']).auto_paginate do |job|
-        @pipeline_types.merge(pipeline_types_for(job))
-      end
-
-      Gitlab.pipeline_jobs(ENV['CI_PROJECT_ID'], ENV['CI_PIPELINE_ID']).auto_paginate do |job|
-        @pipeline_types.merge(pipeline_types_for(job))
-      end
-    rescue Gitlab::Error::Error => error
-      puts "GitLab error: #{error}"
-      exit_allow_to_fail
-    end
-
-    pipeline_name = "#{ENV['CI_PIPELINE_NAME']} [types: #{sorted_pipeline_types.join(',')}]"
-
-    puts "Found pipeline types: #{pipeline_types.to_a}"
-    puts "New pipeline name: #{pipeline_name}"
-
-    set_pipeline_name(pipeline_name)
-  end
-
-  private
-
-  attr_accessor :pipeline_types
-
-  def pipeline_types_for(job)
-    pipeline_types = Set.new
-    pipeline_types << 'rspec-predictive' if RSPEC_PREDICTIVE.include?(job.name)
-    pipeline_types << 'qa-gdk'           if QA_GDK.include?(job.name)
-    pipeline_types << 'review-app'       if REVIEW_APP.include?(job.name)
-    pipeline_types << 'qa'               if QA.include?(job.name)
-    pipeline_types << 'docs'             if DOCS.include?(job.name)
-    pipeline_types << 'code'             if CODE.include?(job.name)
-    pipeline_types
-  end
-
-  def sorted_pipeline_types
-    pipeline_types.sort_by { |type| PIPELINE_TYPES_ORDERED.index(type) }
-  end
-
-  def set_pipeline_name(pipeline_name)
-    # TODO: Create an issue in the gitlab gem to add this one
-    uri = URI("#{ENV['CI_API_V4_URL']}/projects/#{ENV['CI_PROJECT_ID']}/pipelines/#{ENV['CI_PIPELINE_ID']}/metadata")
-    Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
-      request = Net::HTTP::Put.new uri
-      request['JOB-TOKEN'] = ENV['CI_JOB_TOKEN']
-      request.body = "name=#{pipeline_name}"
-      response = http.request request
-
-      if response.code != '200'
-        puts "Failed to set pipeline name: #{response.body}"
-        exit_allow_to_fail
-      end
-    end
-  end
-
-  # Exit with a different error code, so that we can allow the CI job to fail
-  def exit_allow_to_fail
-    exit 3
-  end
-end
-
-if $PROGRAM_NAME == __FILE__
-  Gitlab.configure do |config|
-    config.endpoint      = ENV['CI_API_V4_URL']
-    config.private_token = ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE']
-  end
-
-  SetPipelineName.new.execute
-end
diff --git a/scripts/pipeline/set_pipeline_name.rb b/scripts/pipeline/set_pipeline_name.rb
new file mode 100755
index 0000000000000000000000000000000000000000..fd7a7d2f21cab6214abbe83d0e468389a4ea5bd2
--- /dev/null
+++ b/scripts/pipeline/set_pipeline_name.rb
@@ -0,0 +1,158 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# We need to take some precautions when using the `gitlab` gem in this project.
+#
+# See https://docs.gitlab.com/ee/development/pipelines/internals.html#using-the-gitlab-ruby-gem-in-the-canonical-project.
+require 'gitlab' unless Object.const_defined?(:Gitlab)
+require 'net/http'
+
+class SetPipelineName
+  DOCS                   = ['docs-lint markdown', 'docs-lint links'].freeze
+  RSPEC_PREDICTIVE       = ['rspec:predictive:trigger', 'rspec-ee:predictive:trigger'].freeze
+  CODE                   = ['retrieve-tests-metadata'].freeze
+  QA_GDK                 = ['e2e:test-on-gdk'].freeze
+  REVIEW_APP             = ['start-review-app-pipeline'].freeze
+  #  TODO: Please remove `trigger-omnibus-and-follow-up-e2e` and `follow-up-e2e:package-and-test-ee`
+  #        after 2025-04-08 in this project
+  #
+  #   `trigger-omnibus-and-follow-up-e2e` was renamed to `follow-up:trigger-omnibus` on 2024-04-08 via
+  #    https://gitlab.com/gitlab-org/gitlab/-/merge_requests/147908/diffs?pin=c11467759d7eae77ed84e02a5445e21704c8d8e5#c11467759d7eae77ed84e02a5445e21704c8d8e5_105_104
+  #
+  #   `follow-up-e2e:package-and-test-ee` was renamed to `follow-up:e2e:package-and-test-ee` on 2024-04-08 via
+  #    https://gitlab.com/gitlab-org/gitlab/-/merge_requests/147908/diffs?pin=c11467759d7eae77ed84e02a5445e21704c8d8e5#c11467759d7eae77ed84e02a5445e21704c8d8e5_136_137
+  QA                     = [
+    'e2e:package-and-test-ce',
+    'e2e:package-and-test-ee',
+    'follow-up-e2e:package-and-test-ee',
+    'follow-up:e2e:package-and-test-ee',
+    'follow-up:trigger-omnibus',
+    'trigger-omnibus-and-follow-up-e2e'
+  ].freeze
+  # Ordered by expected duration, DESC
+  PIPELINE_TYPES_ORDERED = %w[qa review-app qa-gdk code rspec-predictive docs].freeze
+
+  # We need an access token that isn't CI_JOB_TOKEN because we are querying
+  # the pipelines API to fetch jobs and bridge jobs.
+  # We are still using CI_JOB_TOKEN to update the pipeline name.
+  #
+  # See https://docs.gitlab.com/ee/ci/jobs/ci_job_token.html for more info.
+  def initialize(api_endpoint:, gitlab_access_token:)
+    @api_endpoint          = api_endpoint
+    @gitlab_access_token   = gitlab_access_token
+  end
+
+  def gitlab
+    @gitlab ||= Gitlab.client(
+      endpoint: @api_endpoint,
+      private_token: @gitlab_access_token
+    )
+  end
+
+  def execute
+    # If we already added metadata to the pipeline name, we discard it, and recompute it.
+    #
+    # This is in case we retry a CI job that runs this script.
+    pipeline_name = ENV['CI_PIPELINE_NAME'].sub(/ \[.*\]\z/, '')
+
+    pipeline_suffixes         = {}
+    pipeline_suffixes[:tier]  = pipeline_tier || 'N/A'
+    pipeline_suffixes[:types] = pipeline_types.join(',')
+
+    pipeline_suffix = pipeline_suffixes.map { |key, value| "#{key}:#{value}" }.join(', ')
+    pipeline_name += " [#{pipeline_suffix}]"
+
+    puts "New pipeline name: #{pipeline_name}"
+
+    set_pipeline_name(pipeline_name)
+  rescue Gitlab::Error::Error => error
+    puts "GitLab error: #{error}"
+    allow_to_fail_return_code
+  end
+
+  private
+
+  def pipeline_tier
+    return unless ENV['CI_MERGE_REQUEST_LABELS']
+
+    # The first pipeline of any MR won't have any tier label, unless the label was added in the MR description
+    # before creating the MR. This is a known limitation.
+    #
+    # Fetching the labels from the API instead of relying on ENV['CI_MERGE_REQUEST_LABELS']
+    # would solve this problem, but it would also mean that we would update the tier information
+    # based on the merge request labels at the time of retrying the job, which isn't what we want.
+    merge_request_labels = ENV['CI_MERGE_REQUEST_LABELS'].split(',')
+    puts "Labels from the MR: #{merge_request_labels}"
+
+    tier_label = merge_request_labels.find { |label| label.start_with?('pipeline::tier-') }
+    return if tier_label.nil?
+
+    tier_label[/\d+\z/]
+  end
+
+  def pipeline_types
+    types = Set.new
+
+    gitlab.pipeline_bridges(ENV['CI_PROJECT_ID'], ENV['CI_PIPELINE_ID']).auto_paginate do |job|
+      types.merge(pipeline_types_for(job))
+    end
+
+    gitlab.pipeline_jobs(ENV['CI_PROJECT_ID'], ENV['CI_PIPELINE_ID']).auto_paginate do |job|
+      types.merge(pipeline_types_for(job))
+    end
+
+    types.sort_by { |type| PIPELINE_TYPES_ORDERED.index(type) }
+  end
+
+  def pipeline_types_for(job)
+    types = Set.new
+    types << 'rspec-predictive' if RSPEC_PREDICTIVE.include?(job.name)
+    types << 'qa-gdk'           if QA_GDK.include?(job.name)
+    types << 'review-app'       if REVIEW_APP.include?(job.name)
+    types << 'qa'               if QA.include?(job.name)
+    types << 'docs'             if DOCS.include?(job.name)
+    types << 'code'             if CODE.include?(job.name)
+    types
+  end
+
+  def set_pipeline_name(pipeline_name)
+    # TODO: Replace with the following once a version of the gem is above 4.19.0:
+    #
+    # Gitlab.update_pipeline_metadata(ENV['CI_PROJECT_ID'], ENV['CI_PIPELINE_ID'], name: pipeline_name)
+    #
+    # New endpoint added in https://github.com/NARKOZ/gitlab/pull/685 (merged on 2024-04-30)
+    # Latest release was made on 2022-07-10: https://github.com/NARKOZ/gitlab/releases/tag/v4.19.0
+    uri = URI("#{ENV['CI_API_V4_URL']}/projects/#{ENV['CI_PROJECT_ID']}/pipelines/#{ENV['CI_PIPELINE_ID']}/metadata")
+    success = false
+    error   = nil
+    Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
+      request = Net::HTTP::Put.new uri
+      request['JOB-TOKEN'] = ENV['CI_JOB_TOKEN']
+      request.body = "name=#{pipeline_name}"
+      response = http.request request
+
+      if response.code == '200'
+        success = true
+      else
+        error = response.body
+      end
+    end
+
+    return 0 if success
+
+    puts "Failed to set pipeline name: #{error}"
+    allow_to_fail_return_code
+  end
+
+  # Exit with a different error code, so that we can allow the CI job to fail
+  def allow_to_fail_return_code
+    3
+  end
+end
+
+if $PROGRAM_NAME == __FILE__
+  exit SetPipelineName.new(
+    api_endpoint: ENV['CI_API_V4_URL'],
+    gitlab_access_token: ENV['PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE']
+  ).execute
+end
diff --git a/spec/scripts/pipeline/set_pipeline_name_spec.rb b/spec/scripts/pipeline/set_pipeline_name_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..86b4befa2532a6c7b83d4165ff76c0b9e217ff7a
--- /dev/null
+++ b/spec/scripts/pipeline/set_pipeline_name_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+require_relative '../../support/webmock'
+require_relative '../../../scripts/pipeline/set_pipeline_name'
+
+RSpec.describe SetPipelineName, feature_category: :tooling do
+  include StubENV
+
+  let(:instance)               { described_class.new(api_endpoint: 'gitlab.test', gitlab_access_token: 'xxx') }
+  let(:original_pipeline_name) { "Ruby 3.2 MR" }
+  let(:project_id)             { '123' }
+  let(:merge_request_iid)      { '1234' }
+  let(:pipeline_id)            { '5678' }
+  let(:merge_request_labels)   { ['Engineering Productivity', 'type::feature', 'pipeline::tier-3'] }
+
+  let(:put_url) { "https://gitlab.test/api/v4/projects/#{project_id}/pipelines/#{pipeline_id}/metadata" }
+
+  let(:jobs)    { ['docs-lint markdown'] }
+  let(:bridges) { ['rspec:predictive:trigger'] }
+
+  # We need to take some precautions when using the `gitlab` gem in this project.
+  #
+  # See https://docs.gitlab.com/ee/development/pipelines/internals.html#using-the-gitlab-ruby-gem-in-the-canonical-project.
+  #
+  # rubocop:disable RSpec/VerifiedDoubles -- See the disclaimer above
+  before do
+    stub_env(
+      'CI_API_V4_URL' => 'https://gitlab.test/api/v4',
+      'CI_MERGE_REQUEST_IID' => merge_request_iid,
+      'CI_PIPELINE_ID' => pipeline_id,
+      'CI_PIPELINE_NAME' => original_pipeline_name,
+      'CI_PROJECT_ID' => project_id,
+      'PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE' => 'xxx',
+      'CI_MERGE_REQUEST_LABELS' => merge_request_labels.empty? ? nil : merge_request_labels.join(',')
+    )
+
+    stub_request(:put, put_url).to_return(status: 200, body: 'OK')
+
+    # Gitlab client stubbing
+    client = double('GitLab')
+    allow(instance).to receive(:gitlab).and_return(client)
+    allow(client).to yield_jobs(:pipeline_jobs, jobs)
+    allow(client).to yield_jobs(:pipeline_bridges, bridges)
+
+    # Ensure we don't output to stdout while running tests
+    allow(instance).to receive(:puts)
+  end
+
+  def yield_jobs(api_method, jobs)
+    messages = receive_message_chain(api_method, :auto_paginate)
+
+    jobs.inject(messages) do |stub, job_name|
+      stub.and_yield(double(name: job_name))
+    end
+  end
+  # rubocop:enable RSpec/VerifiedDoubles
+
+  describe '#execute' do
+    context 'when the pipeline is not from a merge request' do
+      let(:merge_request_iid)    { nil }
+      let(:merge_request_labels) { [] }
+
+      it 'does not add a pipeline tier' do
+        instance.execute
+
+        expect(WebMock).to have_requested(:put, put_url).with { |req| req.body.include?('tier:N/A') }
+      end
+
+      it 'changes the pipeline types' do
+        instance.execute
+
+        # Why not a block with do..end? https://github.com/bblimke/webmock/issues/174#issuecomment-34908908
+        expect(WebMock).to have_requested(:put, put_url).with { |req|
+          req.body.include?('types:rspec-predictive,docs')
+        }
+      end
+    end
+
+    context 'when the pipeline is from a merge request' do
+      it 'adds a pipeline tier' do
+        instance.execute
+
+        expect(WebMock).to have_requested(:put, put_url).with { |req| req.body.include?('tier:3') }
+      end
+
+      it 'adds the pipeline types' do
+        instance.execute
+
+        # Why not a block with do..end? https://github.com/bblimke/webmock/issues/174#issuecomment-34908908
+        expect(WebMock).to have_requested(:put, put_url).with { |req|
+          req.body.include?('types:rspec-predictive,docs')
+        }
+      end
+
+      context 'when the merge request does not have a pipeline tier label' do
+        let(:merge_request_labels) { ['Engineering Productivity', 'type::feature'] }
+
+        it 'adds the N/A pipeline tier' do
+          instance.execute
+
+          expect(WebMock).to have_requested(:put, put_url).with { |req| req.body.include?('tier:N/A') }
+        end
+      end
+    end
+  end
+
+  context 'when we could not update the pipeline name' do
+    before do
+      stub_request(:put, put_url).to_return(status: 502, body: 'NOT OK')
+    end
+
+    it 'displays an error message' do
+      allow(instance).to receive(:puts).and_call_original
+
+      expect { instance.execute }.to output(/Failed to set pipeline name: NOT OK/).to_stdout
+    end
+
+    it 'returns an error code of 3' do
+      expect(instance.execute).to eq(3)
+    end
+  end
+end
diff --git a/spec/support/webmock.rb b/spec/support/webmock.rb
index 491fc7ea64daa359b287f7ff7766847b52fe4580..59c61692aa42e4c0e244f98ab042382e8d5cf553 100644
--- a/spec/support/webmock.rb
+++ b/spec/support/webmock.rb
@@ -24,7 +24,7 @@ def webmock_allowed_hosts
       hosts << Gitlab.config.webpack.dev_server.host
     end
 
-    if ViteRuby.env['VITE_ENABLED'] == "true"
+    if Object.const_defined?(:ViteRuby) && ViteRuby.env['VITE_ENABLED'] == "true"
       hosts << ViteRuby.instance.config.host
       hosts << ViteRuby.env['VITE_HMR_HOST']
     end