diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6853b100ed7b9e2e8cefb16d9e12fb0fc0b659a2..0c06df88b3faedb3bf3785fbcf8856ec8aefb692 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -36,10 +36,21 @@ workflow:
     - if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^release-tools\/\d+\.\d+\.\d+-rc\d+$/ && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^[\d-]+-stable(-ee)?$/ && $CI_PROJECT_PATH == "gitlab-org/gitlab"'
       when: never
     # For merged result pipelines, set $QA_IMAGE, since $CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is only available for merged result pipelines.
-    - if: '$CI_MERGE_REQUEST_EVENT_TYPE == "merged_result" || $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train"'
+    # AND
+    # For merge requests running exclusively in Ruby 3.0
+    - if: '($CI_MERGE_REQUEST_EVENT_TYPE == "merged_result" || $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train") && $CI_MERGE_REQUEST_LABELS =~ /pipeline:run-in-ruby3/'
       variables:
         QA_IMAGE: "${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab-ee-qa:${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA}"
-    # Also run (detached) merge request pipelines.
+        RUBY_VERSION: "3.0"
+    # For merged result pipelines, set $QA_IMAGE, since $CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is only available for merged result pipelines.
+    - if: '($CI_MERGE_REQUEST_EVENT_TYPE == "merged_result" || $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train")'
+      variables:
+        QA_IMAGE: "${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab-ee-qa:${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA}"
+    # For merge requests running exclusively in Ruby 3.0
+    - if: '$CI_MERGE_REQUEST_LABELS =~ /pipeline:run-in-ruby3/'
+      variables:
+        RUBY_VERSION: "3.0"
+    # For (detached) merge request pipelines.
     - if: '$CI_MERGE_REQUEST_IID'
     # For the maintenance scheduled pipelines, we set specific variables.
     - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "schedule" && $SCHEDULE_TYPE == "maintenance"'
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml
index fb4016a10f0bd4bf7c2160d072bf248db4805499..0d4bedd96bf1976e8fe5b15447ea6a061ca5bc17 100644
--- a/.gitlab/ci/rules.gitlab-ci.yml
+++ b/.gitlab/ci/rules.gitlab-ci.yml
@@ -49,6 +49,9 @@
 .if-merge-request-targeting-stable-branch: &if-merge-request-targeting-stable-branch
   if: '$CI_MERGE_REQUEST_IID && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^[\d-]+-stable(-ee)?$/'
 
+.if-merge-request-labels-run-in-ruby3: &if-merge-request-labels-run-in-ruby3
+  if: '$CI_MERGE_REQUEST_LABELS =~ /pipeline:run-in-ruby3/'
+
 .if-merge-request-labels-as-if-foss: &if-merge-request-labels-as-if-foss
   if: '$CI_MERGE_REQUEST_LABELS =~ /pipeline:run-as-if-foss/'
 
@@ -1791,6 +1794,10 @@
     - <<: *if-default-refs
       changes: *code-backstage-patterns
 
+.setup:rules:verify-ruby-2.7:
+  rules:
+    - <<: *if-merge-request-labels-run-in-ruby3
+
 .setup:rules:verify-tests-yml:
   rules:
     - <<: *if-not-ee
diff --git a/.gitlab/ci/setup.gitlab-ci.yml b/.gitlab/ci/setup.gitlab-ci.yml
index 505caeec837a577a4a812546d77e07be73bdaecf..2da397aaab846f98a6179b882284e563a8fd3c73 100644
--- a/.gitlab/ci/setup.gitlab-ci.yml
+++ b/.gitlab/ci/setup.gitlab-ci.yml
@@ -23,13 +23,19 @@ cache gems:
     - .default-retry
   needs: []
 
-dont-interrupt-me:
-  extends: .setup:rules:dont-interrupt-me
-  stage: sync
+.absolutely-minimal-job:
+  extends:
+    - .minimal-job
   image: ${GITLAB_DEPENDENCY_PROXY}alpine:edge
-  interruptible: false
   variables:
     GIT_STRATEGY: none
+
+dont-interrupt-me:
+  extends:
+    - .absolutely-minimal-job
+    - .setup:rules:dont-interrupt-me
+  stage: sync
+  interruptible: false
   script:
     - echo "This jobs makes sure this pipeline won't be interrupted! See https://docs.gitlab.com/ee/ci/yaml/#interruptible."
 
@@ -57,6 +63,15 @@ no-jh-check:
   script:
     - scripts/no-dir-check jh
 
+verify-ruby-2.7:
+  extends:
+    - .absolutely-minimal-job
+    - .setup:rules:verify-ruby-2.7
+  stage: prepare
+  script:
+    - echo 'Please remove label ~"pipeline:run-in-ruby3" so we do test against Ruby 2.7 (default version) before merging the merge request'
+    - exit 1
+
 verify-tests-yml:
   extends:
     - .setup:rules:verify-tests-yml
@@ -70,8 +85,8 @@ verify-tests-yml:
 
 verify-approvals:
   extends:
+    - .minimal-job
     - .setup:rules:jh-contribution
-  needs: []
   script:
     - source scripts/utils.sh
     - install_gitlab_gem
diff --git a/doc/development/pipelines.md b/doc/development/pipelines.md
index d4395cc4e4ba6d4e42823256c8730ca57891d133..14308ebf051773302e8ab9a6c93a6deb8053f40c 100644
--- a/doc/development/pipelines.md
+++ b/doc/development/pipelines.md
@@ -275,6 +275,19 @@ rather than from the default branch `main-jh`.
 NOTE:
 For now, CI will try to fetch the branch on the [GitLab JH mirror](https://gitlab.com/gitlab-org/gitlab-jh-mirrors/gitlab), so it might take some time for the new JH branch to propagate to the mirror.
 
+## Ruby 3.0 jobs
+
+You can add the `pipeline:run-in-ruby3` label to the merge request to switch
+the Ruby version used for running the whole test suite to 3.0. When you do
+this, the test suite will no longer run in Ruby 2.7 (default), and an
+additional job `verify-ruby-2.7` will also run and always fail to remind us to
+remove the label and run in Ruby 2.7 before merging the merge request.
+
+This should let us:
+
+- Test changes for Ruby 3.0
+- Make sure it will not break anything when it's merged into the default branch
+
 ## `undercover` RSpec test
 
 > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74859) in GitLab 14.6.