diff --git a/lib/gitlab/ci/runner_upgrade_check.rb b/lib/gitlab/ci/runner_upgrade_check.rb
index d251ecf67dc82558aba4cc41e020fc628c887557..ad0fd73e9dab9d5bb6b8ec835e572f09f57ec266 100644
--- a/lib/gitlab/ci/runner_upgrade_check.rb
+++ b/lib/gitlab/ci/runner_upgrade_check.rb
@@ -13,7 +13,7 @@ class RunnerUpgradeCheck
       }.freeze
 
       def check_runner_upgrade_status(runner_version)
-        runner_version = ::Gitlab::VersionInfo.parse(runner_version) unless runner_version.is_a?(::Gitlab::VersionInfo)
+        runner_version = ::Gitlab::VersionInfo.parse(runner_version, parse_suffix: true)
 
         return :invalid unless runner_version.valid?
 
diff --git a/lib/gitlab/version_info.rb b/lib/gitlab/version_info.rb
index 88a5b735d8ca72c972329af60c0b3de229732a20..f967a12b9597b006b4452c9d8dfac4a719be0b1e 100644
--- a/lib/gitlab/version_info.rb
+++ b/lib/gitlab/version_info.rb
@@ -6,20 +6,27 @@ class VersionInfo
 
     attr_reader :major, :minor, :patch
 
-    def self.parse(str)
-      if str && m = str.match(/(\d+)\.(\d+)\.(\d+)/)
-        VersionInfo.new(m[1].to_i, m[2].to_i, m[3].to_i)
+    VERSION_REGEX = /(\d+)\.(\d+)\.(\d+)/.freeze
+
+    def self.parse(str, parse_suffix: false)
+      if str.is_a?(self.class)
+        str
+      elsif str && m = str.match(VERSION_REGEX)
+        VersionInfo.new(m[1].to_i, m[2].to_i, m[3].to_i, parse_suffix ? m.post_match : nil)
       else
         VersionInfo.new
       end
     end
 
-    def initialize(major = 0, minor = 0, patch = 0)
+    def initialize(major = 0, minor = 0, patch = 0, suffix = nil)
       @major = major
       @minor = minor
       @patch = patch
+      @suffix_s = suffix.to_s
     end
 
+    # rubocop:disable Metrics/CyclomaticComplexity
+    # rubocop:disable Metrics/PerceivedComplexity
     def <=>(other)
       return unless other.is_a? VersionInfo
       return unless valid? && other.valid?
@@ -36,19 +43,31 @@ def <=>(other)
         1
       elsif @patch < other.patch
         -1
+      elsif @suffix_s.empty? && other.suffix.present?
+        1
+      elsif other.suffix.empty? && @suffix_s.present?
+        -1
       else
-        0
+        suffix <=> other.suffix
       end
     end
+    # rubocop:enable Metrics/CyclomaticComplexity
+    # rubocop:enable Metrics/PerceivedComplexity
 
     def to_s
       if valid?
-        "%d.%d.%d" % [@major, @minor, @patch]
+        "%d.%d.%d%s" % [@major, @minor, @patch, @suffix_s]
       else
-        "Unknown"
+        'Unknown'
       end
     end
 
+    def suffix
+      @suffix ||= @suffix_s.strip.gsub('-', '.pre.').scan(/\d+|[a-z]+/i).map do |s|
+        /^\d+$/ =~ s ? s.to_i : s
+      end.freeze
+    end
+
     def valid?
       @major >= 0 && @minor >= 0 && @patch >= 0 && @major + @minor + @patch > 0
     end
diff --git a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb
index 3953a1aa81aca2631af3fd3d6942fb005b5b89f1..fd3218d7d845981bec4f37892e2b6df59e529f33 100644
--- a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb
+++ b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb
@@ -21,7 +21,9 @@
     end
 
     context 'with available_runner_releases configured up to 14.1.1' do
-      let(:available_runner_releases) { %w[13.9.0 13.9.1 13.9.2 13.10.0 13.10.1 14.0.0 14.0.1 14.0.2 14.1.0 14.1.1] }
+      let(:available_runner_releases) do
+        %w[13.9.0 13.9.1 13.9.2 13.10.0 13.10.1 14.0.0 14.0.1 14.0.2-rc1 14.0.2 14.1.0 14.1.1]
+      end
 
       context 'with nil runner_version' do
         let(:runner_version) { nil }
@@ -62,10 +64,11 @@
             'v14.1.0/1.1.0'                | :recommended   # suffixes are correctly handled
             'v14.1.0'                      | :recommended   # recommended since even though the GitLab instance is still on 14.0.x, there is a patch release (14.1.1) available which might contain security fixes
             'v14.0.1'                      | :recommended   # recommended upgrade since 14.0.2 is available
+            'v14.0.2-rc1'                  | :recommended   # recommended upgrade since 14.0.2 is available and we'll move out of a release candidate
             'v14.0.2'                      | :not_available # not available since 14.0.2 is the latest 14.0.x release available within the instance's major.minor version
             'v13.10.1'                     | :available     # available upgrade: 14.1.1
-            'v13.10.1~beta.1574.gf6ea9389' | :available     # suffixes are correctly handled
-            'v13.10.1/1.1.0'               | :available     # suffixes are correctly handled
+            'v13.10.1~beta.1574.gf6ea9389' | :recommended   # suffixes are correctly handled, official 13.10.1 is available
+            'v13.10.1/1.1.0'               | :recommended   # suffixes are correctly handled, official 13.10.1 is available
             'v13.10.0'                     | :recommended   # recommended upgrade since 13.10.1 is available
             'v13.9.2'                      | :recommended   # recommended upgrade since backports are no longer released for this version
             'v13.9.0'                      | :recommended   # recommended upgrade since backports are no longer released for this version
diff --git a/spec/lib/gitlab/version_info_spec.rb b/spec/lib/gitlab/version_info_spec.rb
index a77d51fab882b6b46da4fc245abf952cfbcb83d9..6ed094f11c8537daf5771e41c35c3226854df8a0 100644
--- a/spec/lib/gitlab/version_info_spec.rb
+++ b/spec/lib/gitlab/version_info_spec.rb
@@ -2,28 +2,44 @@
 
 require 'fast_spec_helper'
 
-RSpec.describe 'Gitlab::VersionInfo' do
+RSpec.describe Gitlab::VersionInfo do
   before do
-    @unknown = Gitlab::VersionInfo.new
-    @v0_0_1 = Gitlab::VersionInfo.new(0, 0, 1)
-    @v0_1_0 = Gitlab::VersionInfo.new(0, 1, 0)
-    @v1_0_0 = Gitlab::VersionInfo.new(1, 0, 0)
-    @v1_0_1 = Gitlab::VersionInfo.new(1, 0, 1)
-    @v1_1_0 = Gitlab::VersionInfo.new(1, 1, 0)
-    @v2_0_0 = Gitlab::VersionInfo.new(2, 0, 0)
+    @unknown = described_class.new
+    @v0_0_1 = described_class.new(0, 0, 1)
+    @v0_1_0 = described_class.new(0, 1, 0)
+    @v1_0_0 = described_class.new(1, 0, 0)
+    @v1_0_1 = described_class.new(1, 0, 1)
+    @v1_0_1_b1 = described_class.new(1, 0, 1, '-b1')
+    @v1_0_1_rc1 = described_class.new(1, 0, 1, '-rc1')
+    @v1_0_1_rc2 = described_class.new(1, 0, 1, '-rc2')
+    @v1_1_0 = described_class.new(1, 1, 0)
+    @v1_1_0_beta1 = described_class.new(1, 1, 0, '-beta1')
+    @v2_0_0 = described_class.new(2, 0, 0)
+    @v13_10_1_1574_89 = described_class.parse("v13.10.1~beta.1574.gf6ea9389", parse_suffix: true)
+    @v13_10_1_1575_89 = described_class.parse("v13.10.1~beta.1575.gf6ea9389", parse_suffix: true)
+    @v13_10_1_1575_90 = described_class.parse("v13.10.1~beta.1575.gf6ea9390", parse_suffix: true)
   end
 
   describe '>' do
     it { expect(@v2_0_0).to be > @v1_1_0 }
     it { expect(@v1_1_0).to be > @v1_0_1 }
+    it { expect(@v1_0_1_b1).to be > @v1_0_0 }
+    it { expect(@v1_0_1_rc1).to be > @v1_0_0 }
+    it { expect(@v1_0_1_rc1).to be > @v1_0_1_b1 }
+    it { expect(@v1_0_1_rc2).to be > @v1_0_1_rc1 }
+    it { expect(@v1_0_1).to be > @v1_0_1_rc1 }
+    it { expect(@v1_0_1).to be > @v1_0_1_rc2 }
     it { expect(@v1_0_1).to be > @v1_0_0 }
     it { expect(@v1_0_0).to be > @v0_1_0 }
+    it { expect(@v1_1_0_beta1).to be > @v1_0_1_rc2 }
+    it { expect(@v1_1_0).to be > @v1_1_0_beta1 }
     it { expect(@v0_1_0).to be > @v0_0_1 }
   end
 
   describe '>=' do
-    it { expect(@v2_0_0).to be >= Gitlab::VersionInfo.new(2, 0, 0) }
+    it { expect(@v2_0_0).to be >= described_class.new(2, 0, 0) }
     it { expect(@v2_0_0).to be >= @v1_1_0 }
+    it { expect(@v1_0_1_rc2).to be >= @v1_0_1_rc1 }
   end
 
   describe '<' do
@@ -31,64 +47,115 @@
     it { expect(@v0_1_0).to be < @v1_0_0 }
     it { expect(@v1_0_0).to be < @v1_0_1 }
     it { expect(@v1_0_1).to be < @v1_1_0 }
+    it { expect(@v1_0_0).to be < @v1_0_1_rc2 }
+    it { expect(@v1_0_1_rc1).to be < @v1_0_1 }
+    it { expect(@v1_0_1_rc1).to be < @v1_0_1_rc2 }
+    it { expect(@v1_0_1_rc2).to be < @v1_0_1 }
     it { expect(@v1_1_0).to be < @v2_0_0 }
+    it { expect(@v13_10_1_1574_89).to be < @v13_10_1_1575_89 }
+    it { expect(@v13_10_1_1575_89).to be < @v13_10_1_1575_90 }
   end
 
   describe '<=' do
-    it { expect(@v0_0_1).to be <= Gitlab::VersionInfo.new(0, 0, 1) }
+    it { expect(@v0_0_1).to be <= described_class.new(0, 0, 1) }
     it { expect(@v0_0_1).to be <= @v0_1_0 }
+    it { expect(@v1_0_1_b1).to be <= @v1_0_1_rc1 }
+    it { expect(@v1_0_1_rc1).to be <= @v1_0_1_rc2 }
+    it { expect(@v1_1_0_beta1).to be <= @v1_1_0 }
   end
 
   describe '==' do
-    it { expect(@v0_0_1).to eq(Gitlab::VersionInfo.new(0, 0, 1)) }
-    it { expect(@v0_1_0).to eq(Gitlab::VersionInfo.new(0, 1, 0)) }
-    it { expect(@v1_0_0).to eq(Gitlab::VersionInfo.new(1, 0, 0)) }
+    it { expect(@v0_0_1).to eq(described_class.new(0, 0, 1)) }
+    it { expect(@v0_1_0).to eq(described_class.new(0, 1, 0)) }
+    it { expect(@v1_0_0).to eq(described_class.new(1, 0, 0)) }
+    it { expect(@v1_0_1_rc1).to eq(described_class.new(1, 0, 1, '-rc1')) }
   end
 
   describe '!=' do
     it { expect(@v0_0_1).not_to eq(@v0_1_0) }
+    it { expect(@v1_0_1_rc1).not_to eq(@v1_0_1_rc2) }
   end
 
   describe '.unknown' do
     it { expect(@unknown).not_to be @v0_0_1 }
-    it { expect(@unknown).not_to be Gitlab::VersionInfo.new }
+    it { expect(@unknown).not_to be described_class.new }
     it { expect {@unknown > @v0_0_1}.to raise_error(ArgumentError) }
     it { expect {@unknown < @v0_0_1}.to raise_error(ArgumentError) }
   end
 
   describe '.parse' do
-    it { expect(Gitlab::VersionInfo.parse("1.0.0")).to eq(@v1_0_0) }
-    it { expect(Gitlab::VersionInfo.parse("1.0.0.1")).to eq(@v1_0_0) }
-    it { expect(Gitlab::VersionInfo.parse("1.0.0-ee")).to eq(@v1_0_0) }
-    it { expect(Gitlab::VersionInfo.parse("1.0.0-rc1")).to eq(@v1_0_0) }
-    it { expect(Gitlab::VersionInfo.parse("1.0.0-rc1-ee")).to eq(@v1_0_0) }
-    it { expect(Gitlab::VersionInfo.parse("git 1.0.0b1")).to eq(@v1_0_0) }
-    it { expect(Gitlab::VersionInfo.parse("git 1.0b1")).not_to be_valid }
+    it { expect(described_class.parse("1.0.0")).to eq(@v1_0_0) }
+    it { expect(described_class.parse("1.0.0.1")).to eq(@v1_0_0) }
+    it { expect(described_class.parse("1.0.0-ee")).to eq(@v1_0_0) }
+    it { expect(described_class.parse("1.0.0-rc1")).to eq(@v1_0_0) }
+    it { expect(described_class.parse("1.0.0-rc1-ee")).to eq(@v1_0_0) }
+    it { expect(described_class.parse("git 1.0.0b1")).to eq(@v1_0_0) }
+    it { expect(described_class.parse("git 1.0b1")).not_to be_valid }
+
+    context 'with parse_suffix: true' do
+      let(:versions) do
+        <<-VERSIONS.lines
+        0.0.1
+        0.1.0
+        1.0.0
+        1.0.1-b1
+        1.0.1-rc1
+        1.0.1-rc2
+        1.0.1
+        1.1.0-beta1
+        1.1.0
+        2.0.0
+        v13.10.0-pre
+        v13.10.0-rc1
+        v13.10.0-rc2
+        v13.10.0
+        v13.10.1~beta.1574.gf6ea9389
+        v13.10.1~beta.1575.gf6ea9389
+        v13.10.1-rc1
+        v13.10.1-rc2
+        v13.10.1
+        VERSIONS
+      end
+
+      let(:parsed_versions) do
+        versions.map(&:strip).map { |version| described_class.parse(version, parse_suffix: true) }
+      end
+
+      it 'versions are returned in a correct order' do
+        expect(parsed_versions.shuffle.sort).to eq(parsed_versions)
+      end
+    end
   end
 
   describe '.to_s' do
     it { expect(@v1_0_0.to_s).to eq("1.0.0") }
+    it { expect(@v1_0_1_rc1.to_s).to eq("1.0.1-rc1") }
     it { expect(@unknown.to_s).to eq("Unknown") }
   end
 
   describe '.hash' do
-    it { expect(Gitlab::VersionInfo.parse("1.0.0").hash).to eq(@v1_0_0.hash) }
-    it { expect(Gitlab::VersionInfo.parse("1.0.0.1").hash).to eq(@v1_0_0.hash) }
-    it { expect(Gitlab::VersionInfo.parse("1.0.1b1").hash).to eq(@v1_0_1.hash) }
+    it { expect(described_class.parse("1.0.0").hash).to eq(@v1_0_0.hash) }
+    it { expect(described_class.parse("1.0.0.1").hash).to eq(@v1_0_0.hash) }
+    it { expect(described_class.parse("1.0.1b1").hash).to eq(@v1_0_1.hash) }
+    it { expect(described_class.parse("1.0.1-rc1", parse_suffix: true).hash).to eq(@v1_0_1_rc1.hash) }
   end
 
   describe '.eql?' do
-    it { expect(Gitlab::VersionInfo.parse("1.0.0").eql?(@v1_0_0)).to be_truthy }
-    it { expect(Gitlab::VersionInfo.parse("1.0.0.1").eql?(@v1_0_0)).to be_truthy }
+    it { expect(described_class.parse("1.0.0").eql?(@v1_0_0)).to be_truthy }
+    it { expect(described_class.parse("1.0.0.1").eql?(@v1_0_0)).to be_truthy }
+    it { expect(@v1_0_1_rc1.eql?(@v1_0_1_rc1)).to be_truthy }
+    it { expect(@v1_0_1_rc1.eql?(@v1_0_1_rc2)).to be_falsey }
+    it { expect(@v1_0_1_rc1.eql?(@v1_0_1)).to be_falsey }
     it { expect(@v1_0_1.eql?(@v1_0_0)).to be_falsey }
     it { expect(@v1_1_0.eql?(@v1_0_0)).to be_falsey }
     it { expect(@v1_0_0.eql?(@v1_0_0)).to be_truthy }
-    it { expect([@v1_0_0, @v1_1_0, @v1_0_0].uniq).to eq [@v1_0_0, @v1_1_0] }
+    it { expect([@v1_0_0, @v1_1_0, @v1_0_0, @v1_0_1_rc1, @v1_0_1_rc1].uniq).to eq [@v1_0_0, @v1_1_0, @v1_0_1_rc1] }
   end
 
   describe '.same_minor_version?' do
     it { expect(@v0_1_0.same_minor_version?(@v0_0_1)).to be_falsey }
     it { expect(@v1_0_1.same_minor_version?(@v1_0_0)).to be_truthy }
+    it { expect(@v1_0_1_rc1.same_minor_version?(@v1_0_0)).to be_truthy }
     it { expect(@v1_0_0.same_minor_version?(@v1_0_1)).to be_truthy }
     it { expect(@v1_1_0.same_minor_version?(@v1_0_0)).to be_falsey }
     it { expect(@v2_0_0.same_minor_version?(@v1_0_0)).to be_falsey }
@@ -98,5 +165,6 @@
     it { expect(@v0_1_0.without_patch).to eq(@v0_1_0) }
     it { expect(@v1_0_0.without_patch).to eq(@v1_0_0) }
     it { expect(@v1_0_1.without_patch).to eq(@v1_0_0) }
+    it { expect(@v1_0_1_rc1.without_patch).to eq(@v1_0_0) }
   end
 end