diff --git a/doc/development/project_templates.md b/doc/development/project_templates.md
index 50d0bfd3c62b1caa0404da1b021125b961b31e22..269724c0a7f3b060c00c157b57105f7901c5855b 100644
--- a/doc/development/project_templates.md
+++ b/doc/development/project_templates.md
@@ -138,3 +138,12 @@ Sometimes it is necessary to completely replace the template files. In this case
 ## For GitLab team members
 
 Please ensure the merge request has been reviewed by the Security Counterpart before merging.
+
+To review a merge request which changes a vendored project template, run the `check-template-changes` script:
+
+```shell
+scripts/check-template-changes vendor/project_templates/<template_name>.tar.gz
+```
+
+This script outputs a diff of the file changes against the default branch and also verifies that
+the template repository matches the source template project.
diff --git a/scripts/check-template-changes b/scripts/check-template-changes
new file mode 100755
index 0000000000000000000000000000000000000000..1a3060fe1bbcb1154e72a828b365622627118982
--- /dev/null
+++ b/scripts/check-template-changes
@@ -0,0 +1,105 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'tmpdir'
+
+@template = ARGV.first
+
+if @template.nil?
+  puts "Usage: #{__FILE__} <path_to_project_template>"
+  exit 1
+end
+
+@name = File.basename(@template).delete_suffix('.tar.gz')
+@extracted_template_dir = Dir.mktmpdir(@name)
+@master_template_dir = Dir.mktmpdir(@name)
+
+def extract(dest)
+  system('tar', 'xf', @template, '-C', dest, exception: true)
+end
+
+def cleanup
+  FileUtils.rm_rf(@extracted_template_dir)
+  FileUtils.rm_rf(@master_template_dir)
+end
+
+def repo_details
+  Dir.chdir(@extracted_template_dir) do
+    system('git', 'clone', 'project.bundle', @name, exception: true)
+  end
+
+  Dir.chdir(File.join(@extracted_template_dir, @name)) do
+    head_commit = `git cat-file -p HEAD`
+    lines = head_commit.split("\n")
+
+    repository = lines
+      .find { |line| line.start_with?('Template repository: ') }
+      .rpartition(' ').last
+
+    commit_sha = lines
+      .find { |line| line.start_with?('Commit SHA: ') }
+      .rpartition(' ').last
+
+    [repository, commit_sha]
+  end
+end
+
+puts "Extracting template to: #{@extracted_template_dir}"
+
+extract(@extracted_template_dir)
+branch = `git rev-parse --abbrev-ref HEAD`.chomp
+system('git', 'checkout', 'master', exception: true)
+extract(@master_template_dir)
+system('git', 'checkout', branch, exception: true)
+
+puts
+puts '🧐 Comparing new template with master'
+puts
+
+system('git', '--no-pager', 'diff', '--no-index', @master_template_dir, @extracted_template_dir)
+
+puts
+puts '--- end diff ---'
+
+repository, commit_sha = repo_details
+
+puts
+puts "📝 Template is created from #{repository} at commit #{commit_sha}"
+
+unless repository.start_with?('https://gitlab.com/gitlab-org/project-templates/')
+  puts '❌ This template does not have the correct origin'
+  cleanup
+  exit 1
+end
+
+puts '🧐 Verifying that template repo matches remote'
+puts
+
+remote_repo_dir = Dir.mktmpdir(@name)
+
+system('git', 'clone', repository, remote_repo_dir, exception: true)
+
+Dir.chdir(remote_repo_dir) do
+  system('git', 'checkout', commit_sha, exception: true)
+  system('git', '--no-pager', 'show')
+end
+
+extracted_template_repo_dir = File.join(@extracted_template_dir, @name)
+
+FileUtils.rm_rf(File.join(extracted_template_repo_dir, '.git'))
+FileUtils.cp_r(File.join(remote_repo_dir, '.git'), extracted_template_repo_dir)
+
+Dir.chdir(extracted_template_repo_dir) do
+  status = `git status`
+  puts status
+  puts
+
+  if status.include?('nothing to commit, working tree clean')
+    puts "✅ Template is up to date with remote commit #{commit_sha}"
+  else
+    puts '❌ Template is not synced with remote'
+  end
+end
+
+FileUtils.rm_rf(remote_repo_dir)
+cleanup