Merge pull request #9529 from reitermarkus/livecheck-cask-strategies

Add more livecheck strategies for casks.
This commit is contained in:
Sam Ford 2020-12-20 02:47:43 -05:00 committed by GitHub
commit 29e310c3f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 509 additions and 50 deletions

View File

@ -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(",")

View File

@ -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"

View File

@ -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

View 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

View File

@ -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]

View File

@ -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"

View 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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View 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

View File

@ -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)