Support plugin and Theme compatibility version manifests (#9995)

Support plugin and Theme compatibility version manifests (#9995)

Adds a new rake task plugin:checkout_compatible_all and plugin:checkout_compatible[plugin-name] that check out compatible plugin versions.

Supports a .discourse-compatibility file in the root of plugins and themes that list out a plugin’s compatibility with certain discourse versions:

eg: .discourse-compatibility

2.5.0.beta6: some-git-hash
2.4.4.beta4: some-git-tag
2.2.0: git-reference

This ensures older Discourse installs are able to find and install older versions of plugins without intervention, through the manifest only.

It iterates through the versions in descending order. If the current Discourse version matches an item in the manifest, it checks out the listed plugin target. If the Discourse version is greater than an item in the manifest, it checks out the next highest version listed in the manifest.

If no versions match, it makes no change.

diff --git a/lib/tasks/plugin.rake b/lib/tasks/plugin.rake
index 9da1f43..a5d2636 100644
--- a/lib/tasks/plugin.rake
+++ b/lib/tasks/plugin.rake
@@ -88,6 +88,45 @@ task 'plugin:update', :plugin do |t, args|
   abort('Unable to pull latest version of plugin') unless update_status
 end
 
+desc 'pull compatible plugin versions for all plugins'
+task 'plugin:pull_compatible_all' do |t|
+  # Loop through each directory
+  plugins = Dir.glob(File.expand_path('plugins/*')).select { |f| File.directory? f }
+  # run plugin:pull_compatible
+  plugins.each do |plugin|
+    next unless File.directory?(plugin + "/.git")
+    Rake::Task['plugin:pull_compatible'].invoke(plugin)
+    Rake::Task['plugin:pull_compatible'].reenable
+  end
+end
+
+desc 'pull a compatible plugin version'
+task 'plugin:pull_compatible', :plugin do |t, args|
+
+  plugin = ENV['PLUGIN'] || ENV['plugin'] || args[:plugin]
+  plugin_path = plugin
+  plugin = File.basename(plugin)
+
+  unless File.directory?(plugin_path)
+    if File.directory?('plugins/' + plugin)
+      plugin_path = File.expand_path('plugins/' + plugin)
+    else
+      abort('Plugin ' + plugin + ' not found')
+    end
+  end
+
+  checkout_version = Discourse.find_compatible_git_resource(plugin_path)
+
+  # Checkout value of the version compat
+  if checkout_version
+    puts "checking out compatible #{plugin} version: #{checkout_version}"
+    update_status = system("git -C '#{plugin_path}' reset --hard #{checkout_version}")
+    abort('Unable to checkout a compatible plugin version') unless update_status
+  else
+    puts "#{plugin} is already at latest compatible version"
+  end
+end
+
 desc 'install all plugin gems'
 task 'plugin:install_all_gems' do |t|
   plugins = Dir.glob(File.expand_path('plugins/*')).select { |f| File.directory? f }
diff --git a/lib/theme_store/git_importer.rb b/lib/theme_store/git_importer.rb
index c73d44f..2419464 100644
--- a/lib/theme_store/git_importer.rb
+++ b/lib/theme_store/git_importer.rb
@@ -23,6 +23,12 @@ class ThemeStore::GitImporter
     else
       import_public!
     end
+    if version = Discourse.find_compatible_git_resource(@temp_folder)
+      Discourse::Utils.execute_command(chdir: @temp_folder) do |runner|
+        Rails.logger.warn "git reset --hard #{version}"
+        return runner.exec("git", "reset", "--hard", version)
+      end
+    end
   end
 
   def diff_local_changes(remote_theme_id)
diff --git a/lib/version.rb b/lib/version.rb
index dbb5a6b..31cd01c 100644
--- a/lib/version.rb
+++ b/lib/version.rb
@@ -3,6 +3,8 @@
 module Discourse
   VERSION_REGEXP = /\A\d+\.\d+\.\d+(\.beta\d+)?\z/ unless defined? ::Discourse::VERSION_REGEXP
 
+  VERSION_COMPATIBILITY_FILENAME = ".discourse-compatibility"
+
   # work around reloader
   unless defined? ::Discourse::VERSION
     module VERSION #:nodoc:
@@ -18,4 +20,40 @@ module Discourse
   def self.has_needed_version?(current, needed)
     Gem::Version.new(current) >= Gem::Version.new(needed)
   end
+
+  # lookup an external resource (theme/plugin)'s best compatible version
+  # compatible resource files are YAML, in the format:
+  # `discourse_version: plugin/theme git reference.` For example:
+  #  2.5.0.beta6: c4a6c17
+  #  2.5.0.beta4: d1d2d3f
+  #  2.5.0.beta2: bbffee
+  #  2.4.4.beta6: some-other-branch-ref
+  #  2.4.2.beta1: v1-tag
+  def self.find_compatible_resource(version_list)
+
+    return unless version_list
+
+    version_list = YAML.load(version_list).sort_by { |version, pin| Gem::Version.new(version) }.reverse
+
+    # If plugin compat version is listed as less than current Discourse version, take the version/hash listed before.
+    checkout_version = nil
+    version_list.each do |core_compat, target|
+      if Gem::Version.new(core_compat) == Gem::Version.new(::Discourse::VERSION::STRING) # Exact version match - return it
+        checkout_version = target
+        break
+      elsif Gem::Version.new(core_compat) < Gem::Version.new(::Discourse::VERSION::STRING) # Core is on a higher version than listed, use a later version
+        break
+      end
+      checkout_version = target
+    end
+
+    checkout_version
+  end
+
+  # Find a compatible resource from a git repo
+  def self.find_compatible_git_resource(path)
+    return unless File.directory?("#{path}/.git")
+    compat_resource, std_error, s = Open3.capture3("git -C '#{path}' show HEAD@{upstream}:#{Discourse::VERSION_COMPATIBILITY_FILENAME}")
+    Discourse.find_compatible_resource(compat_resource) if s.success?
+  end
 end
diff --git a/spec/components/version_spec.rb b/spec/components/version_spec.rb
index 255ef64..e2dc8a5 100644
--- a/spec/components/version_spec.rb
+++ b/spec/components/version_spec.rb
@@ -46,4 +46,61 @@ describe Discourse::VERSION do
     end
 
   end
+
+  context "compatible_resource" do
+    after do
+      # Cleanup versions
+      ::Discourse::VERSION::STRING = [::Discourse::VERSION::MAJOR, ::Discourse::VERSION::MINOR, ::Discourse::VERSION::TINY, ::Discourse::VERSION::PRE].compact.join('.')
+    end
+
+    shared_examples "test compatible resource" do
+      it "returns nil when the current version is above all pinned versions" do
+        ::Discourse::VERSION::STRING = "2.6.0"
+        expect(Discourse.find_compatible_resource(version_list)).to be_nil
+      end
+
+      it "returns the correct version if matches exactly" do
+        ::Discourse::VERSION::STRING = "2.5.0.beta4"
+        expect(Discourse.find_compatible_resource(version_list)).to eq("twofivebetafour")
+      end
+
+      it "returns the closest matching version" do
+        ::Discourse::VERSION::STRING = "2.4.6.beta12"
+        expect(Discourse.find_compatible_resource(version_list)).to eq("twofivebetatwo")
+      end
+
+      it "returns the lowest version possible when using an older version" do
+        ::Discourse::VERSION::STRING = "1.4.6.beta12"
+        expect(Discourse.find_compatible_resource(version_list)).to eq("twofourtwobetaone")
+      end
+    end
+
+    it "returns nil when nil" do
+      expect(Discourse.find_compatible_resource(nil)).to be_nil
+    end
+
+    context "with a regular compatible list" do
+      let(:version_list) { <<~VERSION_LIST
+        2.5.0.beta6: twofivebetasix
+        2.5.0.beta4: twofivebetafour
+        2.5.0.beta2: twofivebetatwo
+        2.4.4.beta6: twofourfourbetasix
+        2.4.2.beta1: twofourtwobetaone
+        VERSION_LIST
+      }
+      include_examples "test compatible resource"
+    end
+
+    context "handle a compatible resource out of order" do
+      let(:version_list) { <<~VERSION_LIST
+        2.4.2.beta1: twofourtwobetaone
+        2.5.0.beta4: twofivebetafour
+        2.5.0.beta6: twofivebetasix
+        2.5.0.beta2: twofivebetatwo
+        2.4.4.beta6: twofourfourbetasix
+        VERSION_LIST
+      }
+      include_examples "test compatible resource"
+    end
+  end
 end

GitHub sha: 339549d1

1 Like

This commit appears in #9995 which was approved by eviltrout. It was merged by featheredtoast.