Merge pull request #9529 from reitermarkus/livecheck-cask-strategies
Add more livecheck strategies for casks.
This commit is contained in:
commit
29e310c3f8
@ -59,7 +59,7 @@ module Homebrew
|
|||||||
[other.version, other.short_version].map { |v| v&.yield_self(&Version.public_method(:new)) }
|
[other.version, other.short_version].map { |v| v&.yield_self(&Version.public_method(:new)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create a nicely formatted version (on a best effor basis).
|
# Create a nicely formatted version (on a best effort basis).
|
||||||
sig { returns(String) }
|
sig { returns(String) }
|
||||||
def nice_version
|
def nice_version
|
||||||
nice_parts.join(",")
|
nice_parts.join(",")
|
||||||
|
@ -298,7 +298,7 @@ module Cask
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_hosting_with_appcast
|
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"
|
add_appcast = "please add an appcast. See https://github.com/Homebrew/homebrew-cask/blob/HEAD/doc/cask_language_reference/stanzas/appcast.md"
|
||||||
|
|
||||||
|
@ -8,6 +8,8 @@
|
|||||||
# This information is used by the `brew livecheck` command to control its
|
# This information is used by the `brew livecheck` command to control its
|
||||||
# behavior.
|
# behavior.
|
||||||
class Livecheck
|
class Livecheck
|
||||||
|
extend Forwardable
|
||||||
|
|
||||||
# A very brief description of why the formula/cask is skipped (e.g. `No longer
|
# A very brief description of why the formula/cask is skipped (e.g. `No longer
|
||||||
# developed or maintained`).
|
# developed or maintained`).
|
||||||
# @return [String, nil]
|
# @return [String, nil]
|
||||||
@ -67,7 +69,9 @@ class Livecheck
|
|||||||
#
|
#
|
||||||
# @param symbol [Symbol] symbol for the desired strategy
|
# @param symbol [Symbol] symbol for the desired strategy
|
||||||
# @return [Symbol, nil]
|
# @return [Symbol, nil]
|
||||||
def strategy(symbol = nil)
|
def strategy(symbol = nil, &block)
|
||||||
|
@strategy_block = block if block
|
||||||
|
|
||||||
case symbol
|
case symbol
|
||||||
when nil
|
when nil
|
||||||
@strategy
|
@strategy
|
||||||
@ -78,6 +82,8 @@ class Livecheck
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr_reader :strategy_block
|
||||||
|
|
||||||
# Sets the `@url` instance variable to the provided argument or returns the
|
# Sets the `@url` instance variable to the provided argument or returns the
|
||||||
# `@url` instance variable when no argument is provided. The argument can be
|
# `@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
|
# a `String` (a URL) or a supported `Symbol` corresponding to a URL in the
|
||||||
@ -103,6 +109,9 @@ class Livecheck
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
delegate version: :@formula_or_cask
|
||||||
|
private :version
|
||||||
|
|
||||||
# Returns a `Hash` of all instance variable values.
|
# Returns a `Hash` of all instance variable values.
|
||||||
# @return [Hash]
|
# @return [Hash]
|
||||||
def to_hash
|
def to_hash
|
||||||
|
12
Library/Homebrew/livecheck/error.rb
Normal file
12
Library/Homebrew/livecheck/error.rb
Normal file
@ -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
|
@ -1,6 +1,7 @@
|
|||||||
# typed: false
|
# typed: false
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "livecheck/error"
|
||||||
require "livecheck/strategy"
|
require "livecheck/strategy"
|
||||||
require "ruby-progressbar"
|
require "ruby-progressbar"
|
||||||
require "uri"
|
require "uri"
|
||||||
@ -30,6 +31,7 @@ module Homebrew
|
|||||||
STRATEGY_SYMBOLS_TO_SKIP_PREPROCESS_URL = [
|
STRATEGY_SYMBOLS_TO_SKIP_PREPROCESS_URL = [
|
||||||
:github_latest,
|
:github_latest,
|
||||||
:page_match,
|
:page_match,
|
||||||
|
:sparkle,
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
UNSTABLE_VERSION_KEYWORDS = %w[
|
UNSTABLE_VERSION_KEYWORDS = %w[
|
||||||
@ -144,7 +146,7 @@ module Homebrew
|
|||||||
|
|
||||||
if latest.blank?
|
if latest.blank?
|
||||||
no_versions_msg = "Unable to get versions"
|
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]
|
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)
|
status_hash(formula_or_cask, "error", [e.to_s], full_name: full_name, verbose: verbose)
|
||||||
elsif !quiet
|
elsif !quiet
|
||||||
onoe "#{Tty.blue}#{formula_or_cask_name(formula_or_cask, full_name: full_name)}#{Tty.reset}: #{e}"
|
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
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -268,6 +271,7 @@ module Homebrew
|
|||||||
# @return [Hash, nil, Boolean]
|
# @return [Hash, nil, Boolean]
|
||||||
def skip_conditions(formula_or_cask, json: false, full_name: false, quiet: false, verbose: false)
|
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)
|
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?
|
if formula&.deprecated? && !formula.livecheckable?
|
||||||
return status_hash(formula, "deprecated", full_name: full_name, verbose: verbose) if json
|
return status_hash(formula, "deprecated", full_name: full_name, verbose: verbose) if json
|
||||||
@ -276,6 +280,13 @@ module Homebrew
|
|||||||
return
|
return
|
||||||
end
|
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?
|
if formula&.disabled? && !formula.livecheckable?
|
||||||
return status_hash(formula, "disabled", full_name: full_name, verbose: verbose) if json
|
return status_hash(formula, "disabled", full_name: full_name, verbose: verbose) if json
|
||||||
|
|
||||||
@ -290,6 +301,20 @@ module Homebrew
|
|||||||
return
|
return
|
||||||
end
|
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?
|
if formula&.head_only? && !formula.any_version_installed?
|
||||||
head_only_msg = "HEAD only formula must be installed to be livecheckable"
|
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
|
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?
|
has_livecheckable = formula_or_cask.livecheckable?
|
||||||
livecheck = formula_or_cask.livecheck
|
livecheck = formula_or_cask.livecheck
|
||||||
|
livecheck_url = livecheck.url
|
||||||
livecheck_regex = livecheck.regex
|
livecheck_regex = livecheck.regex
|
||||||
livecheck_strategy = livecheck.strategy
|
livecheck_strategy = livecheck.strategy
|
||||||
livecheck_url = livecheck.url
|
|
||||||
|
|
||||||
urls = [livecheck_url] if livecheck_url.present?
|
urls = [livecheck_url] if livecheck_url.present?
|
||||||
urls ||= checkable_urls(formula_or_cask)
|
urls ||= checkable_urls(formula_or_cask)
|
||||||
@ -452,7 +477,9 @@ module Homebrew
|
|||||||
strategies = Strategy.from_url(
|
strategies = Strategy.from_url(
|
||||||
url,
|
url,
|
||||||
livecheck_strategy: livecheck_strategy,
|
livecheck_strategy: livecheck_strategy,
|
||||||
|
url_provided: livecheck_url.present?,
|
||||||
regex_provided: livecheck_regex.present?,
|
regex_provided: livecheck_regex.present?,
|
||||||
|
block_provided: livecheck.strategy_block.present?,
|
||||||
)
|
)
|
||||||
strategy = Strategy.from_symbol(livecheck_strategy)
|
strategy = Strategy.from_symbol(livecheck_strategy)
|
||||||
strategy ||= strategies.first
|
strategy ||= strategies.first
|
||||||
@ -467,8 +494,13 @@ module Homebrew
|
|||||||
puts "Regex: #{livecheck_regex.inspect}" if livecheck_regex.present?
|
puts "Regex: #{livecheck_regex.inspect}" if livecheck_regex.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
if livecheck_strategy == :page_match && livecheck_regex.blank?
|
if livecheck_strategy == :page_match && (livecheck_regex.blank? && livecheck.strategy_block.blank?)
|
||||||
odebug "#{strategy_name} strategy requires a regex"
|
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
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -479,7 +511,7 @@ module Homebrew
|
|||||||
|
|
||||||
next if strategy.blank?
|
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]
|
match_version_map = strategy_data[:matches]
|
||||||
regex = strategy_data[:regex]
|
regex = strategy_data[:regex]
|
||||||
|
|
||||||
|
@ -57,12 +57,12 @@ module Homebrew
|
|||||||
# @param regex_provided [Boolean] whether a regex is provided in the
|
# @param regex_provided [Boolean] whether a regex is provided in the
|
||||||
# `livecheck` block
|
# `livecheck` block
|
||||||
# @return [Array]
|
# @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|
|
usable_strategies = strategies.values.select do |strategy|
|
||||||
if strategy == PageMatch
|
if strategy == PageMatch
|
||||||
# Only treat the `PageMatch` strategy as usable if a regex is
|
# Only treat the `PageMatch` strategy as usable if a regex is
|
||||||
# present in the `livecheck` block
|
# present in the `livecheck` block
|
||||||
next unless regex_provided
|
next unless regex_provided || block_provided
|
||||||
elsif strategy.const_defined?(:PRIORITY) &&
|
elsif strategy.const_defined?(:PRIORITY) &&
|
||||||
!strategy::PRIORITY.positive? &&
|
!strategy::PRIORITY.positive? &&
|
||||||
from_symbol(livecheck_strategy) != strategy
|
from_symbol(livecheck_strategy) != strategy
|
||||||
@ -80,6 +80,49 @@ module Homebrew
|
|||||||
(strategy.const_defined?(:PRIORITY) ? -strategy::PRIORITY : -DEFAULT_PRIORITY)
|
(strategy.const_defined?(:PRIORITY) ? -strategy::PRIORITY : -DEFAULT_PRIORITY)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
@ -92,9 +135,11 @@ require_relative "strategy/github_latest"
|
|||||||
require_relative "strategy/gnome"
|
require_relative "strategy/gnome"
|
||||||
require_relative "strategy/gnu"
|
require_relative "strategy/gnu"
|
||||||
require_relative "strategy/hackage"
|
require_relative "strategy/hackage"
|
||||||
|
require_relative "strategy/header_match"
|
||||||
require_relative "strategy/launchpad"
|
require_relative "strategy/launchpad"
|
||||||
require_relative "strategy/npm"
|
require_relative "strategy/npm"
|
||||||
require_relative "strategy/page_match"
|
require_relative "strategy/page_match"
|
||||||
require_relative "strategy/pypi"
|
require_relative "strategy/pypi"
|
||||||
require_relative "strategy/sourceforge"
|
require_relative "strategy/sourceforge"
|
||||||
|
require_relative "strategy/sparkle"
|
||||||
require_relative "strategy/xorg"
|
require_relative "strategy/xorg"
|
||||||
|
78
Library/Homebrew/livecheck/strategy/header_match.rb
Normal file
78
Library/Homebrew/livecheck/strategy/header_match.rb
Normal file
@ -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
|
@ -30,8 +30,8 @@ module Homebrew
|
|||||||
URL_MATCH_REGEX = %r{^https?://}i.freeze
|
URL_MATCH_REGEX = %r{^https?://}i.freeze
|
||||||
|
|
||||||
# Whether the strategy can be applied to the provided URL.
|
# Whether the strategy can be applied to the provided URL.
|
||||||
# PageMatch will technically match any HTTP URL but it's only usable
|
# PageMatch will technically match any HTTP URL but is only
|
||||||
# when the formula has a `livecheck` block containing a regex.
|
# usable with a `livecheck` block containing a regex.
|
||||||
#
|
#
|
||||||
# @param url [String] the URL to match against
|
# @param url [String] the URL to match against
|
||||||
# @return [Boolean]
|
# @return [Boolean]
|
||||||
@ -46,10 +46,28 @@ module Homebrew
|
|||||||
# @param regex [Regexp] a regex used for matching versions in the
|
# @param regex [Regexp] a regex used for matching versions in the
|
||||||
# content
|
# content
|
||||||
# @return [Array]
|
# @return [Array]
|
||||||
def self.page_matches(url, regex)
|
def self.page_matches(url, regex, &block)
|
||||||
page = URI.parse(url).open.read
|
page = Strategy.page_content(url)
|
||||||
matches = page.scan(regex)
|
|
||||||
matches.map(&:first).uniq
|
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
|
end
|
||||||
|
|
||||||
# Checks the content at the URL for new versions, using the provided
|
# 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 url [String] the URL of the content to check
|
||||||
# @param regex [Regexp] a regex used for matching versions in content
|
# @param regex [Regexp] a regex used for matching versions in content
|
||||||
# @return [Hash]
|
# @return [Hash]
|
||||||
def self.find_versions(url, regex)
|
def self.find_versions(url, regex, &block)
|
||||||
match_data = { matches: {}, regex: regex, url: url }
|
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)
|
match_data[:matches][match] = Version.new(match)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
109
Library/Homebrew/livecheck/strategy/sparkle.rb
Normal file
109
Library/Homebrew/livecheck/strategy/sparkle.rb
Normal file
@ -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
|
@ -20,6 +20,24 @@ describe Homebrew::Livecheck do
|
|||||||
end
|
end
|
||||||
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
|
let(:f_deprecated) do
|
||||||
formula("test_deprecated") do
|
formula("test_deprecated") do
|
||||||
desc "Deprecated test formula"
|
desc "Deprecated test formula"
|
||||||
@ -29,6 +47,24 @@ describe Homebrew::Livecheck do
|
|||||||
end
|
end
|
||||||
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
|
let(:f_disabled) do
|
||||||
formula("test_disabled") do
|
formula("test_disabled") do
|
||||||
desc "Disabled test formula"
|
desc "Disabled test formula"
|
||||||
@ -38,11 +74,39 @@ describe Homebrew::Livecheck do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:f_gist) do
|
let(:f_versioned) do
|
||||||
formula("test_gist") do
|
formula("test@0.0.1") do
|
||||||
desc "Gist test formula"
|
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"
|
homepage "https://brew.sh"
|
||||||
url "https://gist.github.com/Homebrew/0000000000"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -54,6 +118,14 @@ describe Homebrew::Livecheck do
|
|||||||
end
|
end
|
||||||
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
|
let(:f_skip) do
|
||||||
formula("test_skip") do
|
formula("test_skip") do
|
||||||
desc "Skipped test formula"
|
desc "Skipped test formula"
|
||||||
@ -66,31 +138,6 @@ describe Homebrew::Livecheck do
|
|||||||
end
|
end
|
||||||
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
|
describe "::formula_name" do
|
||||||
it "returns the name of the formula" do
|
it "returns the name of the formula" do
|
||||||
expect(livecheck.formula_name(f)).to eq("test")
|
expect(livecheck.formula_name(f)).to eq("test")
|
||||||
@ -132,6 +179,12 @@ describe Homebrew::Livecheck do
|
|||||||
.and not_to_output.to_stderr
|
.and not_to_output.to_stderr
|
||||||
end
|
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
|
it "skips a disabled formula without a livecheckable" do
|
||||||
expect { livecheck.skip_conditions(f_disabled) }
|
expect { livecheck.skip_conditions(f_disabled) }
|
||||||
.to output("test_disabled : disabled\n").to_stdout
|
.to output("test_disabled : disabled\n").to_stdout
|
||||||
@ -144,6 +197,18 @@ describe Homebrew::Livecheck do
|
|||||||
.and not_to_output.to_stderr
|
.and not_to_output.to_stderr
|
||||||
end
|
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
|
it "skips a HEAD-only formula if not installed" do
|
||||||
expect { livecheck.skip_conditions(f_head_only) }
|
expect { livecheck.skip_conditions(f_head_only) }
|
||||||
.to output("test_head_only : HEAD only formula must be installed to be livecheckable\n").to_stdout
|
.to output("test_head_only : HEAD only formula must be installed to be livecheckable\n").to_stdout
|
||||||
|
@ -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
|
74
Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb
Normal file
74
Library/Homebrew/test/livecheck/strategy/sparkle_spec.rb
Normal file
@ -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
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
||||||
|
<channel>
|
||||||
|
<title>Example Changelog</title>
|
||||||
|
<link>#{url}</link>
|
||||||
|
<description>Most recent changes with links to updates.</description>
|
||||||
|
<language>en</language>
|
||||||
|
<item>
|
||||||
|
<title>#{appcast_data[:title]}</title>
|
||||||
|
<sparkle:minimumSystemVersion>10.10</sparkle:minimumSystemVersion>
|
||||||
|
<sparkle:releaseNotesLink>https://www.example.com/example/1.2.3.html</sparkle:releaseNotesLink>
|
||||||
|
<enclosure url="#{appcast_data[:url]}" sparkle:shortVersionString="#{appcast_data[:short_version]}" sparkle:version="#{appcast_data[:version]}" length="12345678" type="application/octet-stream" sparkle:dsaSignature="ABCDEF+GHIJKLMNOPQRSTUVWXYZab/cdefghijklmnopqrst/uvwxyz1234567==" />
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
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
|
@ -318,11 +318,13 @@ class Version
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.parse(spec, detected_from_url: false)
|
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)
|
version.nil? ? NULL : new(version, detected_from_url: detected_from_url)
|
||||||
end
|
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 = Pathname.new(spec) unless spec.is_a? Pathname
|
||||||
|
|
||||||
spec_s = spec.to_s
|
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)
|
m = /[-.vV]?((?:\d+\.)+\d+(?:[-_.]?(?i:alpha|beta|pre|rc)\.?\d{,2})?)/.match(spec_s)
|
||||||
return m.captures.first unless m.nil?
|
return m.captures.first unless m.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
private_class_method :_parse
|
private_class_method :_parse
|
||||||
|
|
||||||
def initialize(val, detected_from_url: false)
|
def initialize(val, detected_from_url: false)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user