diff --git a/.gitlab/ci/preflight.gitlab-ci.yml b/.gitlab/ci/preflight.gitlab-ci.yml
index 426d36b24f4a6fd6505e4be75d4446797b2702c5..cd1a9c753cb4ed6212704f70f01b4f217a969c48 100644
--- a/.gitlab/ci/preflight.gitlab-ci.yml
+++ b/.gitlab/ci/preflight.gitlab-ci.yml
@@ -53,6 +53,18 @@ rails-production-server-boot-puma-cng:
     - retry_times_sleep 10 5 "curl http://127.0.0.1:8080"
     - kill $(jobs -p)
 
+ruby_syntax:
+  extends:
+    - .preflight-job-base
+  before_script:
+    - source scripts/utils.sh
+  image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION}
+  parallel:
+    matrix:
+      - RUBY_VERSION: ["3.0", "3.1", "3.2"]
+  script:
+    - run_timed_command "fail_on_warnings scripts/lint/check_ruby_syntax.rb"
+
 no-ee-check:
   extends:
     - .preflight-job-base
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml
index 646021841b438ace18d68d756b2ae17508d63b7d..65b75c3e237a4eb27d8c03e16eba62b0a30153fc 100644
--- a/.gitlab/ci/rules.gitlab-ci.yml
+++ b/.gitlab/ci/rules.gitlab-ci.yml
@@ -2939,6 +2939,11 @@
     - <<: *if-default-refs
       changes: *code-patterns
 
+.preflight:rules:ruby_syntax:
+  rules:
+    - <<: *if-default-refs
+      changes: *backend-patterns
+
 .preflight:rules:no-ee-check:
   rules:
     - <<: *if-not-foss
diff --git a/ee/spec/lib/audit_events/strategies/group_external_destination_strategy_spec.rb b/ee/spec/lib/audit_events/strategies/group_external_destination_strategy_spec.rb
index cf65b1a0330a1dcfeece5bfa576609b295f64cea..15bfe493864418cbae36a12a7cb23f05e685c8b4 100644
--- a/ee/spec/lib/audit_events/strategies/group_external_destination_strategy_spec.rb
+++ b/ee/spec/lib/audit_events/strategies/group_external_destination_strategy_spec.rb
@@ -98,12 +98,9 @@
           it 'sends the headers with the payload' do
             create_list(:audit_events_streaming_header, 2, external_audit_event_destination: destination)
 
-            # rubocop:disable Lint/DuplicateHashKey
             expected_hash = {
-              /key-\d/ => "bar",
               /key-\d/ => "bar"
             }
-            # rubocop:enable Lint/DuplicateHashKey
 
             expect(Gitlab::HTTP).to receive(:post).with(
               an_instance_of(String), a_hash_including(headers: a_hash_including(expected_hash))
diff --git a/ee/spec/lib/audit_events/strategies/instance_external_destination_strategy_spec.rb b/ee/spec/lib/audit_events/strategies/instance_external_destination_strategy_spec.rb
index 105f562912e2a12ff2493ce2adb455218e289d60..fa1d11106d887134fccdbfdc00268f611b67b502 100644
--- a/ee/spec/lib/audit_events/strategies/instance_external_destination_strategy_spec.rb
+++ b/ee/spec/lib/audit_events/strategies/instance_external_destination_strategy_spec.rb
@@ -93,12 +93,9 @@
               instance_external_audit_event_destination: destination
             )
 
-            # rubocop:disable Lint/DuplicateHashKey
             expected_hash = {
-              /key-\d/ => "bar",
               /key-\d/ => "bar"
             }
-            # rubocop:enable Lint/DuplicateHashKey
 
             expect(Gitlab::HTTP).to receive(:post).with(
               an_instance_of(String), a_hash_including(headers: a_hash_including(expected_hash))
diff --git a/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb b/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb
index c2658e79b4dd8cfec4dbd6afa644babf79f7295f..abae3c5324c9094526fdc277394e525bf5171f5e 100644
--- a/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb
+++ b/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb
@@ -113,12 +113,9 @@
         it 'sends the headers with the payload' do
           create_list(:audit_events_streaming_header, 2, external_audit_event_destination: group.external_audit_event_destinations.last)
 
-          # rubocop:disable Lint/DuplicateHashKey
           expected_hash = {
-            /key-\d/ => "bar",
             /key-\d/ => "bar"
           }
-          # rubocop:enable Lint/DuplicateHashKey
 
           expect(Gitlab::HTTP).to receive(:post).with(an_instance_of(String), a_hash_including(headers: a_hash_including(expected_hash))).once
 
diff --git a/scripts/lint/check_ruby_syntax.rb b/scripts/lint/check_ruby_syntax.rb
new file mode 100755
index 0000000000000000000000000000000000000000..e2cb84a625f7b7b81619098b0d174b8621495b4a
--- /dev/null
+++ b/scripts/lint/check_ruby_syntax.rb
@@ -0,0 +1,25 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require_relative "../../tooling/lib/tooling/check_ruby_syntax"
+
+files = `git ls-files -z`.split("\0")
+
+checker = Tooling::CheckRubySyntax.new(files)
+
+puts format("Checking %{files} Ruby files...", files: checker.ruby_files.size)
+
+errors = checker.run
+
+puts
+
+if errors.any?
+  puts "Syntax errors found (#{errors.size}):"
+  puts errors
+
+  exit 1
+else
+  puts "No syntax errors found."
+
+  exit 0
+end
diff --git a/spec/tooling/lib/tooling/check_ruby_syntax_spec.rb b/spec/tooling/lib/tooling/check_ruby_syntax_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cc1bc8e6223024e47e040c37d989b643214ad800
--- /dev/null
+++ b/spec/tooling/lib/tooling/check_ruby_syntax_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require "fast_spec_helper"
+require "fileutils"
+require "rspec-parameterized"
+
+require_relative "../../../../tooling/lib/tooling/check_ruby_syntax"
+
+RSpec.describe Tooling::CheckRubySyntax, feature_category: :tooling do
+  let(:files) { Dir.glob("**/*") }
+
+  subject(:checker) { described_class.new(files) }
+
+  around do |example|
+    Dir.mktmpdir do |dir|
+      Dir.chdir(dir) do
+        example.run
+      end
+    end
+  end
+
+  describe "#ruby_files" do
+    subject { checker.ruby_files }
+
+    context "without files" do
+      it { is_expected.to eq([]) }
+    end
+
+    context "with files ending with .rb" do
+      before do
+        FileUtils.touch("foo.rb")
+        FileUtils.touch("bar.rb")
+        FileUtils.touch("baz.erb")
+      end
+
+      it { is_expected.to contain_exactly("foo.rb", "bar.rb") }
+    end
+
+    context "with special Ruby files" do
+      let(:files) do
+        %w[foo/Guardfile danger/Dangerfile gems/Gemfile Rakefile]
+      end
+
+      before do
+        files.each do |file|
+          FileUtils.mkdir_p(File.dirname(file))
+          FileUtils.touch(file)
+        end
+      end
+
+      it { is_expected.to match_array(files) }
+    end
+  end
+
+  describe "#run" do
+    subject(:errors) { checker.run }
+
+    shared_examples "no errors" do
+      it { is_expected.to be_empty }
+    end
+
+    context "without files" do
+      include_examples "no errors"
+    end
+
+    context "with perfect Ruby code" do
+      before do
+        File.write("perfect.rb", "perfect = code")
+      end
+
+      include_examples "no errors"
+    end
+
+    context "with invalid Ruby code" do
+      before do
+        File.write("invalid.rb", "invalid,")
+      end
+
+      it "has errors" do
+        expect(errors).to include(a_kind_of(SyntaxError))
+      end
+    end
+  end
+end
diff --git a/tooling/lib/tooling/check_ruby_syntax.rb b/tooling/lib/tooling/check_ruby_syntax.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f769ca0199138b16635b9a5d0a53bf87fa949707
--- /dev/null
+++ b/tooling/lib/tooling/check_ruby_syntax.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby 3.1 and earlier needs this. Drop this line after Ruby 3.2+ is only supported.
+
+module Tooling
+  # Checks passed files for valid Ruby syntax.
+  #
+  # It does not check for compile time warnings yet. See https://gitlab.com/-/snippets/1929968
+  class CheckRubySyntax
+    VALID_RUBYFILES = %w[Rakefile Dangerfile Gemfile Guardfile].to_set.freeze
+
+    attr_reader :files
+
+    def initialize(files)
+      @files = files
+    end
+
+    def ruby_files
+      @ruby_files ||=
+        @files.select do |file|
+          file.end_with?(".rb") || VALID_RUBYFILES.include?(File.basename(file))
+        end
+    end
+
+    def run
+      ruby_files.filter_map do |file|
+        check_ruby(file)
+      end
+    end
+
+    private
+
+    def check_ruby(file)
+      RubyVM::InstructionSequence.compile(File.open(file), file)
+
+      nil
+    rescue SyntaxError => e
+      e
+    end
+  end
+end