diff --git a/.rubocop_todo/rspec/verified_doubles.yml b/.rubocop_todo/rspec/verified_doubles.yml
index df5e30e94ed67e6e62f4ad0f2416b2234f291a5a..420b51316c334782d8632d72eba3af50f51a20e7 100644
--- a/.rubocop_todo/rspec/verified_doubles.yml
+++ b/.rubocop_todo/rspec/verified_doubles.yml
@@ -442,7 +442,6 @@ RSpec/VerifiedDoubles:
     - spec/lib/gitlab/ci/build/policy/variables_spec.rb
     - spec/lib/gitlab/ci/build/policy_spec.rb
     - spec/lib/gitlab/ci/build/prerequisite/kubernetes_namespace_spec.rb
-    - spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb
     - spec/lib/gitlab/ci/build/rules/rule_spec.rb
     - spec/lib/gitlab/ci/build/rules_spec.rb
     - spec/lib/gitlab/ci/build/status/reason_spec.rb
diff --git a/lib/gitlab/ci/build/rules/rule/clause/changes.rb b/lib/gitlab/ci/build/rules/rule/clause/changes.rb
index 4c5f02b4f7b56e8a7ae6e149bf1487d70da45f2e..6eba3160249df1f7e9fdbb993c7e1946ab95486a 100644
--- a/lib/gitlab/ci/build/rules/rule/clause/changes.rb
+++ b/lib/gitlab/ci/build/rules/rule/clause/changes.rb
@@ -4,8 +4,10 @@ module Gitlab
   module Ci
     module Build
       class Rules::Rule::Clause::Changes < Rules::Rule::Clause
+        include Gitlab::Utils::StrongMemoize
+
         def initialize(globs)
-          @globs = Array(globs)
+          @globs = globs
         end
 
         def satisfied_by?(pipeline, context)
@@ -19,13 +21,21 @@ def satisfied_by?(pipeline, context)
           end
         end
 
+        private
+
         def expand_globs(context)
-          return @globs unless context
+          return paths unless context
 
-          @globs.map do |glob|
+          paths.map do |glob|
             ExpandVariables.expand_existing(glob, -> { context.variables_hash })
           end
         end
+
+        def paths
+          strong_memoize(:paths) do
+            Array(@globs[:paths])
+          end
+        end
       end
     end
   end
diff --git a/lib/gitlab/ci/config/entry/rules/rule/changes.rb b/lib/gitlab/ci/config/entry/rules/rule/changes.rb
index be57e089f34c9da8ae7b48032b3ae1b4aeb711e5..a56b928450abca62a2c87c12f35a6408d8d3f28e 100644
--- a/lib/gitlab/ci/config/entry/rules/rule/changes.rb
+++ b/lib/gitlab/ci/config/entry/rules/rule/changes.rb
@@ -6,13 +6,51 @@ class Config
       module Entry
         class Rules
           class Rule
-            class Changes < ::Gitlab::Config::Entry::Node
-              include ::Gitlab::Config::Entry::Validatable
+            class Changes < ::Gitlab::Config::Entry::Simplifiable
+              strategy :SimpleChanges, if: -> (config) { config.is_a?(Array) }
+              strategy :ComplexChanges, if: -> (config) { config.is_a?(Hash) }
 
-              validations do
-                validates :config,
-                          array_of_strings: true,
-                          length: { maximum: 50, too_long: "has too many entries (maximum %{count})" }
+              class SimpleChanges < ::Gitlab::Config::Entry::Node
+                include ::Gitlab::Config::Entry::Validatable
+
+                validations do
+                  validates :config,
+                            array_of_strings: true,
+                            length: { maximum: 50, too_long: "has too many entries (maximum %{count})" }
+                end
+
+                def value
+                  {
+                    paths: config
+                  }.compact
+                end
+              end
+
+              class ComplexChanges < ::Gitlab::Config::Entry::Node
+                include ::Gitlab::Config::Entry::Validatable
+                include ::Gitlab::Config::Entry::Attributable
+
+                ALLOWED_KEYS = %i[paths].freeze
+                REQUIRED_KEYS = %i[paths].freeze
+
+                attributes ALLOWED_KEYS
+
+                validations do
+                  validates :config, allowed_keys: ALLOWED_KEYS
+                  validates :config, required_keys: REQUIRED_KEYS
+
+                  with_options allow_nil: false do
+                    validates :paths,
+                              array_of_strings: true,
+                              length: { maximum: 50, too_long: "has too many entries (maximum %{count})" }
+                  end
+                end
+              end
+
+              class UnknownStrategy < ::Gitlab::Config::Entry::Node
+                def errors
+                  ["#{location} should be an array or a hash"]
+                end
               end
             end
           end
diff --git a/spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb b/spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb
index 4ac8bf617380e1e3f543cb53e768d04cef6c3e8c..da9d3fab869007d47802c8d98d0d99555d1b10f7 100644
--- a/spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb
+++ b/spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb
@@ -6,19 +6,41 @@
   describe '#satisfied_by?' do
     subject { described_class.new(globs).satisfied_by?(pipeline, context) }
 
-    it_behaves_like 'a glob matching rule' do
+    context 'a glob matching rule' do
+      using RSpec::Parameterized::TableSyntax
+
       let(:pipeline) { build(:ci_pipeline) }
       let(:context) {}
 
       before do
         allow(pipeline).to receive(:modified_paths).and_return(files.keys)
       end
+
+      # rubocop:disable Layout/LineLength
+      where(:case_name, :globs, :files, :satisfied) do
+        'exact top-level match'      | { paths: ['Dockerfile'] }               | { 'Dockerfile' => '', 'Gemfile' => '' }            | true
+        'exact top-level no match'   | { paths: ['Dockerfile'] }               | { 'Gemfile' => '' }                                | false
+        'pattern top-level match'    | { paths: ['Docker*'] }                  | { 'Dockerfile' => '', 'Gemfile' => '' }            | true
+        'pattern top-level no match' | { paths: ['Docker*'] }                  | { 'Gemfile' => '' }                                | false
+        'exact nested match'         | { paths: ['project/build.properties'] } | { 'project/build.properties' => '' }               | true
+        'exact nested no match'      | { paths: ['project/build.properties'] } | { 'project/README.md' => '' }                      | false
+        'pattern nested match'       | { paths: ['src/**/*.go'] }              | { 'src/gitlab.com/goproject/goproject.go' => '' }  | true
+        'pattern nested no match'    | { paths: ['src/**/*.go'] }              | { 'src/gitlab.com/goproject/README.md' => '' }     | false
+        'ext top-level match'        | { paths: ['*.go'] }                     | { 'main.go' => '', 'cmd/goproject/main.go' => '' } | true
+        'ext nested no match'        | { paths: ['*.go'] }                     | { 'cmd/goproject/main.go' => '' }                  | false
+        'ext slash no match'         | { paths: ['/*.go'] }                    | { 'main.go' => '', 'cmd/goproject/main.go' => '' } | false
+      end
+      # rubocop:enable Layout/LineLength
+
+      with_them do
+        it { is_expected.to eq(satisfied) }
+      end
     end
 
     context 'when pipeline is nil' do
       let(:pipeline) {}
       let(:context) {}
-      let(:globs) { [] }
+      let(:globs) { { paths: [] } }
 
       it { is_expected.to be_truthy }
     end
@@ -26,8 +48,8 @@
     context 'when using variable expansion' do
       let(:pipeline) { build(:ci_pipeline) }
       let(:modified_paths) { ['helm/test.txt'] }
-      let(:globs) { ['$HELM_DIR/**/*'] }
-      let(:context) { double('context') }
+      let(:globs) { { paths: ['$HELM_DIR/**/*'] } }
+      let(:context) { instance_double(Gitlab::Ci::Build::Context::Base) }
 
       before do
         allow(pipeline).to receive(:modified_paths).and_return(modified_paths)
@@ -58,7 +80,7 @@
       end
 
       context 'when variable expansion does not match' do
-        let(:globs) { ['path/with/$in/it/*'] }
+        let(:globs) { { paths: ['path/with/$in/it/*'] } }
         let(:modified_paths) { ['path/with/$in/it/file.txt'] }
 
         before do
diff --git a/spec/lib/gitlab/ci/build/rules/rule_spec.rb b/spec/lib/gitlab/ci/build/rules/rule_spec.rb
index f905e229415abc1bc0e27fe54b104a0114cc0a57..ac73b665f3ac02d90fc5632f38309237bd731fb1 100644
--- a/spec/lib/gitlab/ci/build/rules/rule_spec.rb
+++ b/spec/lib/gitlab/ci/build/rules/rule_spec.rb
@@ -14,10 +14,14 @@
   let(:ci_build) { build(:ci_build, pipeline: pipeline) }
   let(:rule)     { described_class.new(rule_hash) }
 
+  before do
+    allow(pipeline).to receive(:modified_paths).and_return(['file.rb'])
+  end
+
   describe '#matches?' do
     subject { rule.matches?(pipeline, seed) }
 
-    context 'with one matching clause' do
+    context 'with one matching clause if' do
       let(:rule_hash) do
         { if: '$VAR == null', when: 'always' }
       end
@@ -25,9 +29,17 @@
       it { is_expected.to eq(true) }
     end
 
+    context 'with one matching clause changes' do
+      let(:rule_hash) do
+        { changes: { paths: ['**/*'] }, when: 'always' }
+      end
+
+      it { is_expected.to eq(true) }
+    end
+
     context 'with two matching clauses' do
       let(:rule_hash) do
-        { if: '$VAR == null', changes: '**/*', when: 'always' }
+        { if: '$VAR == null', changes: { paths: ['**/*'] }, when: 'always' }
       end
 
       it { is_expected.to eq(true) }
@@ -35,7 +47,7 @@
 
     context 'with a matching and non-matching clause' do
       let(:rule_hash) do
-        { if: '$VAR != null', changes: '$VAR == null', when: 'always' }
+        { if: '$VAR != null', changes: { paths: ['invalid.xyz'] }, when: 'always' }
       end
 
       it { is_expected.to eq(false) }
@@ -43,7 +55,7 @@
 
     context 'with two non-matching clauses' do
       let(:rule_hash) do
-        { if: '$VAR != null', changes: 'README', when: 'always' }
+        { if: '$VAR != null', changes: { paths: ['README'] }, when: 'always' }
       end
 
       it { is_expected.to eq(false) }
diff --git a/spec/lib/gitlab/ci/config/entry/rules/rule/changes_spec.rb b/spec/lib/gitlab/ci/config/entry/rules/rule/changes_spec.rb
index 3ed4a9f263f1d861dc274d781a57b28d3670615d..295561b3c4d26ac7648969586f07bcbd6d853d72 100644
--- a/spec/lib/gitlab/ci/config/entry/rules/rule/changes_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/rules/rule/changes_spec.rb
@@ -37,7 +37,7 @@
       it { is_expected.not_to be_valid }
 
       it 'reports an error about invalid policy' do
-        expect(entry.errors).to include(/should be an array of strings/)
+        expect(entry.errors).to include(/should be an array or a hash/)
       end
     end
 
@@ -64,7 +64,59 @@
 
       it 'returns information about errors' do
         expect(entry.errors)
-          .to include(/should be an array of strings/)
+          .to include(/should be an array or a hash/)
+      end
+    end
+
+    context 'with paths' do
+      context 'when paths is an array of strings' do
+        let(:config) { { paths: %w[app/ lib/] } }
+
+        it { is_expected.to be_valid }
+      end
+
+      context 'when paths is not an array' do
+        let(:config) { { paths: 'string' } }
+
+        it { is_expected.not_to be_valid }
+
+        it 'returns information about errors' do
+          expect(entry.errors)
+            .to include(/should be an array of strings/)
+        end
+      end
+
+      context 'when paths is an array of integers' do
+        let(:config) { { paths: [1, 2] } }
+
+        it { is_expected.not_to be_valid }
+
+        it 'returns information about errors' do
+          expect(entry.errors)
+            .to include(/should be an array of strings/)
+        end
+      end
+
+      context 'when paths is an array of long strings' do
+        let(:config) { { paths: ['a'] * 51 } }
+
+        it { is_expected.not_to be_valid }
+
+        it 'returns information about errors' do
+          expect(entry.errors)
+            .to include(/has too many entries \(maximum 50\)/)
+        end
+      end
+
+      context 'when paths is nil' do
+        let(:config) { { paths: nil } }
+
+        it { is_expected.not_to be_valid }
+
+        it 'returns information about errors' do
+          expect(entry.errors)
+            .to include(/should be an array of strings/)
+        end
       end
     end
   end
@@ -75,6 +127,14 @@
     context 'when using a string array' do
       let(:config) { %w[app/ lib/ spec/ other/* paths/**/*.rb] }
 
+      it { is_expected.to eq(paths: config) }
+    end
+
+    context 'with paths' do
+      let(:config) do
+        { paths: ['app/', 'lib/'] }
+      end
+
       it { is_expected.to eq(config) }
     end
   end
diff --git a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
index 89d349efe8f5cd4d0770ec549f0f7d2f74bdcfc2..93f4a66bfb6c53c931aa23e8136a6de518fe18c4 100644
--- a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
@@ -115,7 +115,7 @@
       it { is_expected.not_to be_valid }
 
       it 'reports an error about invalid policy' do
-        expect(subject.errors).to include(/should be an array of strings/)
+        expect(subject.errors).to include(/should be an array or a hash/)
       end
     end
 
@@ -411,7 +411,13 @@
     context 'when using a changes: clause' do
       let(:config) { { changes: %w[app/ lib/ spec/ other/* paths/**/*.rb] } }
 
-      it { is_expected.to eq(config) }
+      it { is_expected.to eq(changes: { paths: %w[app/ lib/ spec/ other/* paths/**/*.rb] }) }
+
+      context 'when using changes with paths' do
+        let(:config) { { changes: { paths: %w[app/ lib/ spec/ other/* paths/**/*.rb] } } }
+
+        it { is_expected.to eq(config) }
+      end
     end
 
     context 'when default value has been provided' do
@@ -426,7 +432,7 @@
       end
 
       it 'does not add to provided configuration' do
-        expect(entry.value).to eq(config)
+        expect(entry.value).to eq(changes: { paths: %w[app/**/*.rb] })
       end
     end
 
diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
index 49505d397c2c4b333e03fc97923ca8e090b87f23..040f3ab5830f18265244a7c07bcc4e6fc18f1040 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
@@ -858,14 +858,14 @@
         context 'with an explicit `when: never`' do
           where(:rule_set) do
             [
-              [[{ changes: %w[*/**/*.rb],                 when: 'never' }, { changes: %w[*/**/*.rb],                 when: 'always' }]],
-              [[{ changes: %w[app/models/ci/pipeline.rb], when: 'never' }, { changes: %w[app/models/ci/pipeline.rb], when: 'always' }]],
-              [[{ changes: %w[spec/**/*.rb],              when: 'never' }, { changes: %w[spec/**/*.rb],              when: 'always' }]],
-              [[{ changes: %w[*.yml],                     when: 'never' }, { changes: %w[*.yml],                     when: 'always' }]],
-              [[{ changes: %w[.*.yml],                    when: 'never' }, { changes: %w[.*.yml],                    when: 'always' }]],
-              [[{ changes: %w[**/*],                      when: 'never' }, { changes: %w[**/*],                      when: 'always' }]],
-              [[{ changes: %w[*/**/*.rb *.yml],           when: 'never' }, { changes: %w[*/**/*.rb *.yml],           when: 'always' }]],
-              [[{ changes: %w[.*.yml **/*],               when: 'never' }, { changes: %w[.*.yml **/*],               when: 'always' }]]
+              [[{ changes: { paths: %w[*/**/*.rb] },                 when: 'never' }, { changes: { paths: %w[*/**/*.rb] },                 when: 'always' }]],
+              [[{ changes: { paths: %w[app/models/ci/pipeline.rb] }, when: 'never' }, { changes: { paths: %w[app/models/ci/pipeline.rb] }, when: 'always' }]],
+              [[{ changes: { paths: %w[spec/**/*.rb] },              when: 'never' }, { changes: { paths: %w[spec/**/*.rb] },              when: 'always' }]],
+              [[{ changes: { paths: %w[*.yml] },                     when: 'never' }, { changes: { paths: %w[*.yml] },                     when: 'always' }]],
+              [[{ changes: { paths: %w[.*.yml] },                    when: 'never' }, { changes: { paths: %w[.*.yml] },                    when: 'always' }]],
+              [[{ changes: { paths: %w[**/*] },                      when: 'never' }, { changes: { paths: %w[**/*] },                      when: 'always' }]],
+              [[{ changes: { paths: %w[*/**/*.rb *.yml] },           when: 'never' }, { changes: { paths: %w[*/**/*.rb *.yml] },           when: 'always' }]],
+              [[{ changes: { paths: %w[.*.yml **/*] },               when: 'never' }, { changes: { paths: %w[.*.yml **/*] },               when: 'always' }]]
             ]
           end
 
@@ -881,14 +881,14 @@
         context 'with an explicit `when: always`' do
           where(:rule_set) do
             [
-              [[{ changes: %w[*/**/*.rb],                 when: 'always' }, { changes: %w[*/**/*.rb],                 when: 'never' }]],
-              [[{ changes: %w[app/models/ci/pipeline.rb], when: 'always' }, { changes: %w[app/models/ci/pipeline.rb], when: 'never' }]],
-              [[{ changes: %w[spec/**/*.rb],              when: 'always' }, { changes: %w[spec/**/*.rb],              when: 'never' }]],
-              [[{ changes: %w[*.yml],                     when: 'always' }, { changes: %w[*.yml],                     when: 'never' }]],
-              [[{ changes: %w[.*.yml],                    when: 'always' }, { changes: %w[.*.yml],                    when: 'never' }]],
-              [[{ changes: %w[**/*],                      when: 'always' }, { changes: %w[**/*],                      when: 'never' }]],
-              [[{ changes: %w[*/**/*.rb *.yml],           when: 'always' }, { changes: %w[*/**/*.rb *.yml],           when: 'never' }]],
-              [[{ changes: %w[.*.yml **/*],               when: 'always' }, { changes: %w[.*.yml **/*],               when: 'never' }]]
+              [[{ changes: { paths: %w[*/**/*.rb] },                 when: 'always' }, { changes: { paths: %w[*/**/*.rb] },                 when: 'never' }]],
+              [[{ changes: { paths: %w[app/models/ci/pipeline.rb] }, when: 'always' }, { changes: { paths: %w[app/models/ci/pipeline.rb] }, when: 'never' }]],
+              [[{ changes: { paths: %w[spec/**/*.rb] },              when: 'always' }, { changes: { paths: %w[spec/**/*.rb] },              when: 'never' }]],
+              [[{ changes: { paths: %w[*.yml] },                     when: 'always' }, { changes: { paths: %w[*.yml] },                     when: 'never' }]],
+              [[{ changes: { paths: %w[.*.yml] },                    when: 'always' }, { changes: { paths: %w[.*.yml] },                    when: 'never' }]],
+              [[{ changes: { paths: %w[**/*] },                      when: 'always' }, { changes: { paths: %w[**/*] },                      when: 'never' }]],
+              [[{ changes: { paths: %w[*/**/*.rb *.yml] },           when: 'always' }, { changes: { paths: %w[*/**/*.rb *.yml] },           when: 'never' }]],
+              [[{ changes: { paths: %w[.*.yml **/*] },               when: 'always' }, { changes: { paths: %w[.*.yml **/*] },               when: 'never' }]]
             ]
           end
 
@@ -904,14 +904,14 @@
         context 'without an explicit when: value' do
           where(:rule_set) do
             [
-              [[{ changes: %w[*/**/*.rb]                 }]],
-              [[{ changes: %w[app/models/ci/pipeline.rb] }]],
-              [[{ changes: %w[spec/**/*.rb]              }]],
-              [[{ changes: %w[*.yml]                     }]],
-              [[{ changes: %w[.*.yml]                    }]],
-              [[{ changes: %w[**/*]                      }]],
-              [[{ changes: %w[*/**/*.rb *.yml]           }]],
-              [[{ changes: %w[.*.yml **/*]               }]]
+              [[{ changes:  { paths: %w[*/**/*.rb] }                 }]],
+              [[{ changes:  { paths: %w[app/models/ci/pipeline.rb] } }]],
+              [[{ changes:  { paths: %w[spec/**/*.rb] }              }]],
+              [[{ changes:  { paths: %w[*.yml] }                     }]],
+              [[{ changes:  { paths: %w[.*.yml] }                    }]],
+              [[{ changes:  { paths: %w[**/*] }                      }]],
+              [[{ changes:  { paths: %w[*/**/*.rb *.yml] }           }]],
+              [[{ changes:  { paths: %w[.*.yml **/*] }               }]]
             ]
           end
 
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 15567b3667330a3e6ea598806454de5c76fdfd20..22bc6b0db5974d1fe6fe5d8627242dbf3420561d 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -70,7 +70,7 @@ module Ci
               options: { script: ['rspec'] },
               rules: [
                 { if: '$CI_COMMIT_REF_NAME == "master"' },
-                { changes: %w[README.md] }
+                { changes: { paths: %w[README.md] } }
               ],
               allow_failure: false,
               when: 'on_success',
@@ -2893,6 +2893,51 @@ module Ci
         end
       end
 
+      describe 'Rules' do
+        context 'changes' do
+          let(:config) do
+            <<~YAML
+            rspec:
+              script: exit 0
+              rules:
+                - changes: [README.md]
+            YAML
+          end
+
+          it 'returns builds with correct rules' do
+            expect(processor.builds.size).to eq(1)
+            expect(processor.builds[0]).to match(
+              hash_including(
+                name: "rspec",
+                rules: [{ changes: { paths: ["README.md"] } }]
+              )
+            )
+          end
+
+          context 'with paths' do
+            let(:config) do
+              <<~YAML
+              rspec:
+                script: exit 0
+                rules:
+                  - changes:
+                      paths: [README.md]
+              YAML
+            end
+
+            it 'returns builds with correct rules' do
+              expect(processor.builds.size).to eq(1)
+              expect(processor.builds[0]).to match(
+                hash_including(
+                  name: "rspec",
+                  rules: [{ changes: { paths: ["README.md"] } }]
+                )
+              )
+            end
+          end
+        end
+      end
+
       describe '#execute' do
         subject { Gitlab::Ci::YamlProcessor.new(content).execute }
 
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index aac059f2104437e3fce23bae6362c1b8cea18a35..9cef7f7dadb1113f9bed23bd2d947cbd4ae65cd8 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -2087,6 +2087,12 @@ def find_job(name)
               rules:
                 - changes:
                   - $CI_JOB_NAME*
+
+            changes-paths:
+              script: "I am using a new syntax!"
+              rules:
+                - changes:
+                    paths: [README.md]
           EOY
         end
 
@@ -2098,8 +2104,9 @@ def find_job(name)
 
           it 'creates five jobs' do
             expect(pipeline).to be_persisted
-            expect(build_names)
-              .to contain_exactly('regular-job', 'rules-job', 'delayed-job', 'negligible-job', 'README')
+            expect(build_names).to contain_exactly(
+              'regular-job', 'rules-job', 'delayed-job', 'negligible-job', 'README', 'changes-paths'
+            )
           end
 
           it 'sets when: for all jobs' do