diff --git a/Library/Homebrew/bundle_version.rb b/Library/Homebrew/bundle_version.rb index ca63aa36b8..69abe5cfbd 100644 --- a/Library/Homebrew/bundle_version.rb +++ b/Library/Homebrew/bundle_version.rb @@ -59,7 +59,7 @@ module Homebrew [other.version, other.short_version].map { |v| v&.yield_self(&Version.public_method(:new)) } end - # Create a nicely formatted version (on a best effor basis). + # Create a nicely formatted version (on a best effort basis). sig { returns(String) } def nice_version nice_parts.join(",") diff --git a/Library/Homebrew/cask/audit.rb b/Library/Homebrew/cask/audit.rb index 0e54c062dd..6121c1ac00 100644 --- a/Library/Homebrew/cask/audit.rb +++ b/Library/Homebrew/cask/audit.rb @@ -298,7 +298,7 @@ module Cask end def check_hosting_with_appcast - return if cask.appcast + return if cask.appcast || cask.livecheckable? add_appcast = "please add an appcast. See https://github.com/Homebrew/homebrew-cask/blob/HEAD/doc/cask_language_reference/stanzas/appcast.md" diff --git a/Library/Homebrew/livecheck.rb b/Library/Homebrew/livecheck.rb index 7fb6c3bb2b..43288cdb3c 100644 --- a/Library/Homebrew/livecheck.rb +++ b/Library/Homebrew/livecheck.rb @@ -8,6 +8,8 @@ # This information is used by the `brew livecheck` command to control its # behavior. class Livecheck + extend Forwardable + # A very brief description of why the formula/cask is skipped (e.g. `No longer # developed or maintained`). # @return [String, nil] @@ -67,7 +69,9 @@ class Livecheck # # @param symbol [Symbol] symbol for the desired strategy # @return [Symbol, nil] - def strategy(symbol = nil) + def strategy(symbol = nil, &block) + @strategy_block = block if block + case symbol when nil @strategy @@ -78,6 +82,8 @@ class Livecheck end end + attr_reader :strategy_block + # Sets the `@url` instance variable to the provided argument or returns the # `@url` instance variable when no argument is provided. The argument can be # a `String` (a URL) or a supported `Symbol` corresponding to a URL in the @@ -103,6 +109,9 @@ class Livecheck end end + delegate version: :@formula_or_cask + private :version + # Returns a `Hash` of all instance variable values. # @return [Hash] def to_hash diff --git a/Library/Homebrew/livecheck/error.rb b/Library/Homebrew/livecheck/error.rb new file mode 100644 index 0000000000..a10875551b --- /dev/null +++ b/Library/Homebrew/livecheck/error.rb @@ -0,0 +1,12 @@ +# typed: true +# frozen_string_literal: true + +module Homebrew + module Livecheck + # Error during a livecheck run. + # + # @api private + class Error < RuntimeError + end + end +end diff --git a/Library/Homebrew/livecheck/livecheck.rb b/Library/Homebrew/livecheck/livecheck.rb index 9025a1fb1e..ece9c04656 100644 --- a/Library/Homebrew/livecheck/livecheck.rb +++ b/Library/Homebrew/livecheck/livecheck.rb @@ -1,6 +1,7 @@ # typed: false # frozen_string_literal: true +require "livecheck/error" require "livecheck/strategy" require "ruby-progressbar" require "uri" @@ -30,6 +31,7 @@ module Homebrew STRATEGY_SYMBOLS_TO_SKIP_PREPROCESS_URL = [ :github_latest, :page_match, + :sparkle, ].freeze UNSTABLE_VERSION_KEYWORDS = %w[ @@ -144,7 +146,7 @@ module Homebrew if latest.blank? no_versions_msg = "Unable to get versions" - raise TypeError, no_versions_msg unless json + raise Livecheck::Error, no_versions_msg unless json next version_info if version_info.is_a?(Hash) && version_info[:status] && version_info[:messages] @@ -200,6 +202,7 @@ module Homebrew status_hash(formula_or_cask, "error", [e.to_s], full_name: full_name, verbose: verbose) elsif !quiet onoe "#{Tty.blue}#{formula_or_cask_name(formula_or_cask, full_name: full_name)}#{Tty.reset}: #{e}" + $stderr.puts e.backtrace if debug && !e.is_a?(Livecheck::Error) nil end end @@ -268,6 +271,7 @@ module Homebrew # @return [Hash, nil, Boolean] def skip_conditions(formula_or_cask, json: false, full_name: false, quiet: false, verbose: false) formula = formula_or_cask if formula_or_cask.is_a?(Formula) + cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask) if formula&.deprecated? && !formula.livecheckable? return status_hash(formula, "deprecated", full_name: full_name, verbose: verbose) if json @@ -276,6 +280,13 @@ module Homebrew return end + if cask&.discontinued? && !cask.livecheckable? + return status_hash(cask, "discontinued", full_name: full_name, verbose: verbose) if json + + puts "#{Tty.red}#{cask_name(cask, full_name: full_name)}#{Tty.reset} : discontinued" unless quiet + return + end + if formula&.disabled? && !formula.livecheckable? return status_hash(formula, "disabled", full_name: full_name, verbose: verbose) if json @@ -290,6 +301,20 @@ module Homebrew return end + if cask&.version&.latest? && !cask.livecheckable? + return status_hash(cask, "latest", full_name: full_name, verbose: verbose) if json + + puts "#{Tty.red}#{cask_name(cask, full_name: full_name)}#{Tty.reset} : latest" unless quiet + return + end + + if cask&.url&.unversioned? && !cask.livecheckable? + return status_hash(cask, "unversioned", full_name: full_name, verbose: verbose) if json + + puts "#{Tty.red}#{cask_name(cask, full_name: full_name)}#{Tty.reset} : unversioned" unless quiet + return + end + if formula&.head_only? && !formula.any_version_installed? head_only_msg = "HEAD only formula must be installed to be livecheckable" return status_hash(formula, "error", [head_only_msg], full_name: full_name, verbose: verbose) if json @@ -412,9 +437,9 @@ module Homebrew has_livecheckable = formula_or_cask.livecheckable? livecheck = formula_or_cask.livecheck + livecheck_url = livecheck.url livecheck_regex = livecheck.regex livecheck_strategy = livecheck.strategy - livecheck_url = livecheck.url urls = [livecheck_url] if livecheck_url.present? urls ||= checkable_urls(formula_or_cask) @@ -452,7 +477,9 @@ module Homebrew strategies = Strategy.from_url( url, livecheck_strategy: livecheck_strategy, + url_provided: livecheck_url.present?, regex_provided: livecheck_regex.present?, + block_provided: livecheck.strategy_block.present?, ) strategy = Strategy.from_symbol(livecheck_strategy) strategy ||= strategies.first @@ -467,8 +494,13 @@ module Homebrew puts "Regex: #{livecheck_regex.inspect}" if livecheck_regex.present? end - if livecheck_strategy == :page_match && livecheck_regex.blank? - odebug "#{strategy_name} strategy requires a regex" + if livecheck_strategy == :page_match && (livecheck_regex.blank? && livecheck.strategy_block.blank?) + odebug "#{strategy_name} strategy requires a regex or block" + next + end + + if livecheck_strategy.present? && livecheck_url.blank? + odebug "#{strategy_name} strategy requires a url" next end @@ -479,7 +511,7 @@ module Homebrew next if strategy.blank? - strategy_data = strategy.find_versions(url, livecheck_regex) + strategy_data = strategy.find_versions(url, livecheck_regex, &livecheck.strategy_block) match_version_map = strategy_data[:matches] regex = strategy_data[:regex] diff --git a/Library/Homebrew/livecheck/strategy.rb b/Library/Homebrew/livecheck/strategy.rb index 4115432951..c999fce0d3 100644 --- a/Library/Homebrew/livecheck/strategy.rb +++ b/Library/Homebrew/livecheck/strategy.rb @@ -57,12 +57,12 @@ module Homebrew # @param regex_provided [Boolean] whether a regex is provided in the # `livecheck` block # @return [Array] - def from_url(url, livecheck_strategy: nil, regex_provided: nil) + def from_url(url, livecheck_strategy: nil, url_provided: nil, regex_provided: nil, block_provided: nil) usable_strategies = strategies.values.select do |strategy| if strategy == PageMatch # Only treat the `PageMatch` strategy as usable if a regex is # present in the `livecheck` block - next unless regex_provided + next unless regex_provided || block_provided elsif strategy.const_defined?(:PRIORITY) && !strategy::PRIORITY.positive? && from_symbol(livecheck_strategy) != strategy @@ -80,6 +80,49 @@ module Homebrew (strategy.const_defined?(:PRIORITY) ? -strategy::PRIORITY : -DEFAULT_PRIORITY) end end + + def self.page_headers(url) + @headers ||= {} + + return @headers[url] if @headers.key?(url) + + headers = [] + + [:default, :browser].each do |user_agent| + args = [ + "--head", # Only work with the response headers + "--request", "GET", # Use a GET request (instead of HEAD) + "--silent", # Silent mode + "--location", # Follow redirects + "--connect-timeout", "5", # Max time allowed for connection (secs) + "--max-time", "10" # Max time allowed for transfer (secs) + ] + + stdout, _, status = curl_with_workarounds( + *args, url, + print_stdout: false, print_stderr: false, + debug: false, verbose: false, + user_agent: user_agent, retry: false + ) + + while stdout.match?(/\AHTTP.*\r$/) + h, stdout = stdout.split("\r\n\r\n", 2) + + headers << h.split("\r\n").drop(1) + .map { |header| header.split(/:\s*/, 2) } + .to_h.transform_keys(&:downcase) + end + + return (@headers[url] = headers) if status.success? + end + + headers + end + + def self.page_content(url) + @page_content ||= {} + @page_content[url] ||= URI.parse(url).open.read + end end end end @@ -92,9 +135,11 @@ require_relative "strategy/github_latest" require_relative "strategy/gnome" require_relative "strategy/gnu" require_relative "strategy/hackage" +require_relative "strategy/header_match" require_relative "strategy/launchpad" require_relative "strategy/npm" require_relative "strategy/page_match" require_relative "strategy/pypi" require_relative "strategy/sourceforge" +require_relative "strategy/sparkle" require_relative "strategy/xorg" diff --git a/Library/Homebrew/livecheck/strategy/header_match.rb b/Library/Homebrew/livecheck/strategy/header_match.rb new file mode 100644 index 0000000000..22d3ea7e49 --- /dev/null +++ b/Library/Homebrew/livecheck/strategy/header_match.rb @@ -0,0 +1,78 @@ +# typed: false +# frozen_string_literal: true + +require_relative "page_match" + +module Homebrew + module Livecheck + module Strategy + # The {HeaderMatch} strategy follows all URL redirections and scans + # the resulting headers for matching text using the provided regex. + # + # @api private + class HeaderMatch + extend T::Sig + + NICE_NAME = "Header match" + + # A priority of zero causes livecheck to skip the strategy. We only + # apply {HeaderMatch} using `strategy :header_match` in a `livecheck` + # block, as we can't automatically determine when this can be + # successfully applied to a URL. + PRIORITY = 0 + + # The `Regexp` used to determine if the strategy applies to the URL. + URL_MATCH_REGEX = %r{^https?://}i.freeze + + # Whether the strategy can be applied to the provided URL. + # The strategy will technically match any HTTP URL but is + # only usable with a `livecheck` block containing a regex + # or block. + sig { params(url: String).returns(T::Boolean) } + def self.match?(url) + URL_MATCH_REGEX.match?(url) + end + + # Checks the final URL for new versions after following all redirections, + # using the provided regex for matching. + sig { params(url: String, regex: T.nilable(Regexp)).returns(T::Hash[Symbol, T.untyped]) } + def self.find_versions(url, regex, &block) + match_data = { matches: {}, regex: regex, url: url } + + headers = Strategy.page_headers(url) + + # Merge the headers from all responses into one hash + merged_headers = headers.reduce(&:merge) + + if block + match = block.call(merged_headers) + else + match = nil + + if (filename = merged_headers["content-disposition"]) + if regex + match ||= filename[regex, 1] + else + v = Version.parse(filename, detected_from_url: true) + match ||= v.to_s unless v.null? + end + end + + if (location = merged_headers["location"]) + if regex + match ||= location[regex, 1] + else + v = Version.parse(location, detected_from_url: true) + match ||= v.to_s unless v.null? + end + end + end + + match_data[:matches][match] = Version.new(match) if match + + match_data + end + end + end + end +end diff --git a/Library/Homebrew/livecheck/strategy/page_match.rb b/Library/Homebrew/livecheck/strategy/page_match.rb index 49961eff59..361cf04b97 100644 --- a/Library/Homebrew/livecheck/strategy/page_match.rb +++ b/Library/Homebrew/livecheck/strategy/page_match.rb @@ -30,8 +30,8 @@ module Homebrew URL_MATCH_REGEX = %r{^https?://}i.freeze # Whether the strategy can be applied to the provided URL. - # PageMatch will technically match any HTTP URL but it's only usable - # when the formula has a `livecheck` block containing a regex. + # PageMatch will technically match any HTTP URL but is only + # usable with a `livecheck` block containing a regex. # # @param url [String] the URL to match against # @return [Boolean] @@ -46,10 +46,28 @@ module Homebrew # @param regex [Regexp] a regex used for matching versions in the # content # @return [Array] - def self.page_matches(url, regex) - page = URI.parse(url).open.read - matches = page.scan(regex) - matches.map(&:first).uniq + def self.page_matches(url, regex, &block) + page = Strategy.page_content(url) + + if block + case (value = block.call(page)) + when String + return [value] + when Array + return value + else + raise TypeError, "Return value of `strategy :page_match` block must be a string or array of strings." + end + end + + page.scan(regex).map do |match| + case match + when String + match + else + match.first + end + end.uniq end # Checks the content at the URL for new versions, using the provided @@ -58,10 +76,10 @@ module Homebrew # @param url [String] the URL of the content to check # @param regex [Regexp] a regex used for matching versions in content # @return [Hash] - def self.find_versions(url, regex) + def self.find_versions(url, regex, &block) match_data = { matches: {}, regex: regex, url: url } - page_matches(url, regex).each do |match| + page_matches(url, regex, &block).each do |match| match_data[:matches][match] = Version.new(match) end diff --git a/Library/Homebrew/livecheck/strategy/sparkle.rb b/Library/Homebrew/livecheck/strategy/sparkle.rb new file mode 100644 index 0000000000..63d61a9a9e --- /dev/null +++ b/Library/Homebrew/livecheck/strategy/sparkle.rb @@ -0,0 +1,109 @@ +# typed: true +# frozen_string_literal: true + +require "bundle_version" +require_relative "page_match" + +module Homebrew + module Livecheck + module Strategy + # The {Sparkle} strategy fetches content at a URL and parses + # it as a Sparkle appcast in XML format. + # + # @api private + class Sparkle + extend T::Sig + + # A priority of zero causes livecheck to skip the strategy. We only + # apply {Sparkle} using `strategy :sparkle` in a `livecheck` block, + # as we can't automatically determine when this can be successfully + # applied to a URL without fetching the content. + PRIORITY = 0 + + # The `Regexp` used to determine if the strategy applies to the URL. + URL_MATCH_REGEX = %r{^https?://}i.freeze + + # Whether the strategy can be applied to the provided URL. + # The strategy will technically match any HTTP URL but is + # only usable with a `livecheck` block containing a regex + # or block. + sig { params(url: String).returns(T::Boolean) } + def self.match?(url) + URL_MATCH_REGEX.match?(url) + end + + Item = Struct.new(:title, :url, :bundle_version, :short_version, :version, keyword_init: true) do + extend T::Sig + + extend Forwardable + + delegate version: :bundle_version + delegate short_version: :bundle_version + end + + sig { params(content: String).returns(T.nilable(Item)) } + def self.item_from_content(content) + require "nokogiri" + + xml = Nokogiri::XML(content) + xml.remove_namespaces! + + items = xml.xpath("//rss//channel//item").map do |item| + enclosure = (item > "enclosure").first + + url = enclosure&.attr("url") + short_version = enclosure&.attr("shortVersionString") + version = enclosure&.attr("version") + + url ||= (item > "link").first&.text + short_version ||= (item > "shortVersionString").first&.text&.strip + version ||= (item > "version").first&.text&.strip + + title = (item > "title").first&.text&.strip + + if match = title&.match(/(\d+(?:\.\d+)*)\s*(\([^)]+\))?\Z/) + short_version ||= match[1] + version ||= match[2] + end + + bundle_version = BundleVersion.new(short_version, version) if short_version || version + + data = { + title: title, + url: url, + bundle_version: bundle_version, + short_version: bundle_version&.short_version, + version: bundle_version&.version, + }.compact + + Item.new(**data) unless data.empty? + end.compact + + items.max_by(&:bundle_version) + end + + # Checks the content at the URL for new versions. + sig { params(url: String, regex: T.nilable(Regexp)).returns(T::Hash[Symbol, T.untyped]) } + def self.find_versions(url, regex, &block) + raise ArgumentError, "The #{T.must(name).demodulize} strategy does not support a regex." if regex + + match_data = { matches: {}, regex: regex, url: url } + + content = Strategy.page_content(url) + + if (item = item_from_content(content)) + match = if block + block.call(item)&.to_s + else + item.bundle_version&.nice_version + end + + match_data[:matches][match] = Version.new(match) if match + end + + match_data + end + end + end + end +end diff --git a/Library/Homebrew/test/livecheck/livecheck_spec.rb b/Library/Homebrew/test/livecheck/livecheck_spec.rb index 5873f7ae78..a4cdb6bc4b 100644 --- a/Library/Homebrew/test/livecheck/livecheck_spec.rb +++ b/Library/Homebrew/test/livecheck/livecheck_spec.rb @@ -20,6 +20,24 @@ describe Homebrew::Livecheck do end end + let(:c) do + Cask::CaskLoader.load(+<<-RUBY) + cask "test" do + version "0.0.1,2" + + url "https://brew.sh/test-0.0.1.tgz" + name "Test" + desc "Test cask" + homepage "https://brew.sh" + + livecheck do + url "https://formulae.brew.sh/api/formula/ruby.json" + regex(/"stable":"(\d+(?:\.\d+)+)"/i) + end + end + RUBY + end + let(:f_deprecated) do formula("test_deprecated") do desc "Deprecated test formula" @@ -29,6 +47,24 @@ describe Homebrew::Livecheck do end end + let(:c_discontinued) do + Cask::CaskLoader.load(+<<-RUBY) + cask "test_discontinued" do + version "0.0.1" + sha256 :no_check + + url "https://brew.sh/test-0.0.1.tgz" + name "Test Discontinued" + desc "Discontinued test cask" + homepage "https://brew.sh" + + caveats do + discontinued + end + end + RUBY + end + let(:f_disabled) do formula("test_disabled") do desc "Disabled test formula" @@ -38,11 +74,39 @@ describe Homebrew::Livecheck do end end - let(:f_gist) do - formula("test_gist") do - desc "Gist test formula" + let(:f_versioned) do + formula("test@0.0.1") do + desc "Versioned test formula" + homepage "https://brew.sh" + url "https://brew.sh/test-0.0.1.tgz" + end + end + + let(:c_latest) do + Cask::CaskLoader.load(+<<-RUBY) + cask "test_latest" do + version :latest + sha256 :no_check + + url "https://brew.sh/test-0.0.1.tgz" + name "Test Latest" + desc "Latest test cask" + homepage "https://brew.sh" + end + RUBY + end + + # `URL#unversioned?` doesn't work properly when using the + # `Cask::CaskLoader.load` setup above, so we use `Cask::Cask.new` instead. + let(:c_unversioned) do + Cask::Cask.new "test_unversioned" do + version "1.2.3" + sha256 :no_check + + url "https://brew.sh/test.tgz" + name "Test Unversioned" + desc "Unversioned test cask" homepage "https://brew.sh" - url "https://gist.github.com/Homebrew/0000000000" end end @@ -54,6 +118,14 @@ describe Homebrew::Livecheck do end end + let(:f_gist) do + formula("test_gist") do + desc "Gist test formula" + homepage "https://brew.sh" + url "https://gist.github.com/Homebrew/0000000000" + end + end + let(:f_skip) do formula("test_skip") do desc "Skipped test formula" @@ -66,31 +138,6 @@ describe Homebrew::Livecheck do end end - let(:f_versioned) do - formula("test@0.0.1") do - desc "Versioned test formula" - homepage "https://brew.sh" - url "https://brew.sh/test-0.0.1.tgz" - end - end - - let(:c) do - Cask::CaskLoader.load(+<<-RUBY) - cask "test" do - version "0.0.1,2" - - url "https://brew.sh/test-0.0.1.tgz" - name "Test" - homepage "https://brew.sh" - - livecheck do - url "https://formulae.brew.sh/api/formula/ruby.json" - regex(/"stable":"(\d+(?:\.\d+)+)"/i) - end - end - RUBY - end - describe "::formula_name" do it "returns the name of the formula" do expect(livecheck.formula_name(f)).to eq("test") @@ -132,6 +179,12 @@ describe Homebrew::Livecheck do .and not_to_output.to_stderr end + it "skips a discontinued cask without a livecheckable" do + expect { livecheck.skip_conditions(c_discontinued) } + .to output("test_discontinued : discontinued\n").to_stdout + .and not_to_output.to_stderr + end + it "skips a disabled formula without a livecheckable" do expect { livecheck.skip_conditions(f_disabled) } .to output("test_disabled : disabled\n").to_stdout @@ -144,6 +197,18 @@ describe Homebrew::Livecheck do .and not_to_output.to_stderr end + it "skips a cask containing `version :latest` without a livecheckable" do + expect { livecheck.skip_conditions(c_latest) } + .to output("test_latest : latest\n").to_stdout + .and not_to_output.to_stderr + end + + it "skips a cask containing an unversioned URL without a livecheckable" do + expect { livecheck.skip_conditions(c_unversioned) } + .to output("test_unversioned : unversioned\n").to_stdout + .and not_to_output.to_stderr + end + it "skips a HEAD-only formula if not installed" do expect { livecheck.skip_conditions(f_head_only) } .to output("test_head_only : HEAD only formula must be installed to be livecheckable\n").to_stdout diff --git a/Library/Homebrew/test/livecheck/strategy/header_match_spec.rb b/Library/Homebrew/test/livecheck/strategy/header_match_spec.rb new file mode 100644 index 0000000000..fbb38b8366 --- /dev/null +++ b/Library/Homebrew/test/livecheck/strategy/header_match_spec.rb @@ -0,0 +1,16 @@ +# typed: false +# frozen_string_literal: true + +require "livecheck/strategy/header_match" + +describe Homebrew::Livecheck::Strategy::HeaderMatch do + subject(:header_match) { described_class } + + let(:url) { "https://www.example.com/" } + + describe "::match?" do + it "returns true for any URL" do + expect(header_match.match?(url)).to be true + end + end +end diff --git a/Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb b/Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb new file mode 100644 index 0000000000..f98e43ef00 --- /dev/null +++ b/Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb @@ -0,0 +1,74 @@ +# typed: false +# frozen_string_literal: true + +require "livecheck/strategy/sparkle" + +describe Homebrew::Livecheck::Strategy::Sparkle do + subject(:sparkle) { described_class } + + let(:url) { "https://www.example.com/example/appcast.xml" } + + let(:appcast_data) { + { + title: "Version 1.2.3", + url: "https://www.example.com/example/example.tar.gz", + bundle_version: Homebrew::BundleVersion.new("1.2.3", "1234"), + short_version: "1.2.3", + version: "1234", + } + } + + let(:appcast_item) { + Homebrew::Livecheck::Strategy::Sparkle::Item.new( + { + title: appcast_data[:title], + url: appcast_data[:url], + bundle_version: appcast_data[:bundle_version], + short_version: appcast_data[:bundle_version]&.short_version, + version: appcast_data[:bundle_version]&.version, + }, + ) + } + + let(:appcast_xml) { + <<~EOS + + + + Example Changelog + #{url} + Most recent changes with links to updates. + en + + #{appcast_data[:title]} + 10.10 + https://www.example.com/example/1.2.3.html + + + + + EOS + } + + describe "::match?" do + it "returns true for any URL" do + expect(sparkle.match?(url)).to be true + end + end + + describe "::item_from_content" do + let(:item_from_appcast_xml) { sparkle.item_from_content(appcast_xml) } + + it "returns nil if content is blank" do + expect(sparkle.item_from_content("")).to be nil + end + + it "returns an Item when given XML data" do + expect(item_from_appcast_xml).to be_a(Homebrew::Livecheck::Strategy::Sparkle::Item) + expect(item_from_appcast_xml.title).to eq(appcast_item.title) + expect(item_from_appcast_xml.url).to eq(appcast_item.url) + expect(item_from_appcast_xml.bundle_version.short_version).to eq(appcast_item.bundle_version.short_version) + expect(item_from_appcast_xml.bundle_version.version).to eq(appcast_item.bundle_version.version) + end + end +end diff --git a/Library/Homebrew/version.rb b/Library/Homebrew/version.rb index 8c1735e9a1..c3c86456ac 100644 --- a/Library/Homebrew/version.rb +++ b/Library/Homebrew/version.rb @@ -318,11 +318,13 @@ class Version end def self.parse(spec, detected_from_url: false) - version = _parse(spec) + version = _parse(spec, detected_from_url: detected_from_url) version.nil? ? NULL : new(version, detected_from_url: detected_from_url) end - def self._parse(spec) + def self._parse(spec, detected_from_url:) + spec = CGI.unescape(spec.to_s) if detected_from_url + spec = Pathname.new(spec) unless spec.is_a? Pathname spec_s = spec.to_s @@ -465,7 +467,6 @@ class Version m = /[-.vV]?((?:\d+\.)+\d+(?:[-_.]?(?i:alpha|beta|pre|rc)\.?\d{,2})?)/.match(spec_s) return m.captures.first unless m.nil? end - private_class_method :_parse def initialize(val, detected_from_url: false)