
This adds a `Livecheck::Options` class, which is intended to house various configuration options that are set in `livecheck` blocks, conditionally set by livecheck at runtime, etc. The general idea is that when we add features involving configurations options (e.g., for livecheck, strategies, curl, etc.), we can make changes to `Options` without needing to modify parameters for strategy `find_versions` methods, `Strategy` methods like `page_headers` and `page_content`, etc. This is something that I've been trying to improve over the years and `Options` should help to reduce maintenance overhead in this area while also strengthening type signatures. `Options` replaces the existing `homebrew_curl` option (which related strategies pass to `Strategy` methods and on to `curl_args`) and the new `url_options` (which contains `post_form` or `post_json` values that are used to make `POST` requests). I recently added `url_options` as a temporary way of enabling `POST` support without `Options` but this restores the original `Options`-based implementation. Along the way, I added a `homebrew_curl` parameter to the `url` DSL method, allowing us to set an explicit value in `livecheck` blocks. This is something that we've needed in some cases but I also intend to replace implicit/inferred `homebrew_curl` usage with explicit values in `livecheck` blocks once this is available for use. My intention is to eventually remove the implicit behavior and only rely on explicit values. That will align with how `homebrew_curl` options work for other URLs and makes the behavior clear just from looking at the `livecheck` block. Lastly, this removes the `unused` rest parameter from `find_versions` methods. I originally added `unused` as a way of handling parameters that some `find_versions` methods have but others don't (e.g., `cask` in `ExtractPlist`), as this allowed us to pass various arguments to `find_versions` methods without worrying about whether a particular parameter is available. This isn't an ideal solution and I originally wanted to handle this situation by only passing expected arguments to `find_versions` methods but there was a technical issue standing in the way. I recently found an answer to the issue, so this also replaces the existing `ExtractPlist` special case with generic logic that checks the parameters for a strategy's `find_versions` method and only passes expected arguments. Replacing the aforementioned `find_versions` parameters with `Options` ensures that the remaining parameters are fairly consistent across strategies and any differences are handled by the aforementioned logic. Outside of `ExtractPlist`, the only other difference is that some `find_versions` methods have a `provided_content` parameter but that's currently only used by tests (though it's intended for caching support in the future). I will be renaming that parameter to `content` in an upcoming PR and expanding it to the other strategies, which should make them all consistent outside of `ExtractPlist`.
222 lines
7.8 KiB
Ruby
222 lines
7.8 KiB
Ruby
# typed: strict
|
|
# frozen_string_literal: true
|
|
|
|
require "addressable"
|
|
require "system_command"
|
|
|
|
module Homebrew
|
|
module Livecheck
|
|
module Strategy
|
|
# The {Git} strategy identifies versions of software in a Git repository
|
|
# by checking the tags using `git ls-remote --tags`.
|
|
#
|
|
# Livecheck has historically prioritized the {Git} strategy over others
|
|
# and this behavior was continued when the priority setup was created.
|
|
# This is partly related to Livecheck checking formula URLs in order of
|
|
# `head`, `stable` and then `homepage`. The higher priority here may
|
|
# be removed (or altered) in the future if we reevaluate this particular
|
|
# behavior.
|
|
#
|
|
# This strategy does not have a default regex. Instead, it simply removes
|
|
# any non-digit text from the start of tags and parses the rest as a
|
|
# {Version}. This works for some simple situations but even one unusual
|
|
# tag can cause a bad result. It's better to provide a regex in a
|
|
# `livecheck` block, so `livecheck` only matches what we really want.
|
|
#
|
|
# @api public
|
|
class Git
|
|
extend SystemCommand::Mixin
|
|
|
|
# Used to cache processed URLs, to avoid duplicating effort.
|
|
@processed_urls = T.let({}, T::Hash[String, String])
|
|
|
|
# The priority of the strategy on an informal scale of 1 to 10 (from
|
|
# lowest to highest).
|
|
PRIORITY = 8
|
|
|
|
# The default regex used to naively identify versions from tags when a
|
|
# regex isn't provided.
|
|
DEFAULT_REGEX = /\D*(.+)/
|
|
|
|
GITEA_INSTANCES = T.let(%w[
|
|
codeberg.org
|
|
gitea.com
|
|
opendev.org
|
|
tildegit.org
|
|
].freeze, T::Array[String])
|
|
private_constant :GITEA_INSTANCES
|
|
|
|
GOGS_INSTANCES = T.let(%w[
|
|
lolg.it
|
|
].freeze, T::Array[String])
|
|
private_constant :GOGS_INSTANCES
|
|
|
|
# Processes and returns the URL used by livecheck.
|
|
sig { params(url: String).returns(String) }
|
|
def self.preprocess_url(url)
|
|
processed_url = @processed_urls[url]
|
|
return processed_url if processed_url
|
|
|
|
begin
|
|
uri = Addressable::URI.parse url
|
|
rescue Addressable::URI::InvalidURIError
|
|
return url
|
|
end
|
|
|
|
host = uri.host
|
|
path = uri.path
|
|
return url if host.nil? || path.nil?
|
|
|
|
host = "github.com" if host == "github.s3.amazonaws.com"
|
|
path = path.delete_prefix("/").delete_suffix(".git")
|
|
scheme = uri.scheme
|
|
|
|
if host == "github.com"
|
|
return url if path.match? %r{/releases/latest/?$}
|
|
|
|
owner, repo = path.delete_prefix("downloads/").split("/")
|
|
processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
|
|
elsif GITEA_INSTANCES.include?(host)
|
|
return url if path.match? %r{/releases/latest/?$}
|
|
|
|
owner, repo = path.split("/")
|
|
processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
|
|
elsif GOGS_INSTANCES.include?(host)
|
|
owner, repo = path.split("/")
|
|
processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
|
|
# sourcehut
|
|
elsif host == "git.sr.ht"
|
|
owner, repo = path.split("/")
|
|
processed_url = "#{scheme}://#{host}/#{owner}/#{repo}"
|
|
# GitLab (gitlab.com or self-hosted)
|
|
elsif path.include?("/-/archive/")
|
|
processed_url = url.sub(%r{/-/archive/.*$}i, ".git")
|
|
end
|
|
|
|
if processed_url && (processed_url != url)
|
|
@processed_urls[url] = processed_url
|
|
else
|
|
url
|
|
end
|
|
end
|
|
|
|
# Whether the strategy can be applied to the provided URL.
|
|
#
|
|
# @param url [String] the URL to match against
|
|
# @return [Boolean]
|
|
sig { params(url: String).returns(T::Boolean) }
|
|
def self.match?(url)
|
|
url = preprocess_url(url)
|
|
(DownloadStrategyDetector.detect(url) <= GitDownloadStrategy) == true
|
|
end
|
|
|
|
# Fetches a remote Git repository's tags using `git ls-remote --tags`
|
|
# and parses the command's output. If a regex is provided, it will be
|
|
# used to filter out any tags that don't match it.
|
|
#
|
|
# @param url [String] the URL of the Git repository to check
|
|
# @param regex [Regexp] the regex to use for filtering tags
|
|
# @return [Hash]
|
|
sig { params(url: String, regex: T.nilable(Regexp)).returns(T::Hash[Symbol, T.untyped]) }
|
|
def self.tag_info(url, regex = nil)
|
|
stdout, stderr, _status = system_command(
|
|
"git",
|
|
args: ["ls-remote", "--tags", url],
|
|
env: { "GIT_TERMINAL_PROMPT" => "0" },
|
|
print_stdout: false,
|
|
print_stderr: false,
|
|
debug: false,
|
|
verbose: false,
|
|
)
|
|
|
|
tags_data = { tags: [] }
|
|
tags_data[:messages] = stderr.split("\n") if stderr.present?
|
|
return tags_data if stdout.blank?
|
|
|
|
# Isolate tag strings and filter by regex
|
|
tags = stdout.gsub(%r{^.*\trefs/tags/|\^{}$}, "").split("\n").uniq.sort
|
|
tags.select! { |t| regex.match?(t) } if regex
|
|
tags_data[:tags] = tags
|
|
|
|
tags_data
|
|
end
|
|
|
|
# Identify versions from tag strings using a provided regex or the
|
|
# `DEFAULT_REGEX`. The regex is expected to use a capture group around
|
|
# the version text.
|
|
#
|
|
# @param tags [Array] the tags to identify versions from
|
|
# @param regex [Regexp, nil] a regex to identify versions
|
|
# @return [Array]
|
|
sig {
|
|
params(
|
|
tags: T::Array[String],
|
|
regex: T.nilable(Regexp),
|
|
block: T.nilable(Proc),
|
|
).returns(T::Array[String])
|
|
}
|
|
def self.versions_from_tags(tags, regex = nil, &block)
|
|
if block
|
|
block_return_value = if regex.present?
|
|
yield(tags, regex)
|
|
elsif block.arity == 2
|
|
yield(tags, DEFAULT_REGEX)
|
|
else
|
|
yield(tags)
|
|
end
|
|
return Strategy.handle_block_return(block_return_value)
|
|
end
|
|
|
|
tags.filter_map do |tag|
|
|
if regex
|
|
# Use the first capture group (the version)
|
|
# This code is not typesafe unless the regex includes a capture group
|
|
T.unsafe(tag.scan(regex).first)&.first
|
|
else
|
|
# Remove non-digits from the start of the tag and use that as the
|
|
# version text
|
|
tag[DEFAULT_REGEX, 1]
|
|
end
|
|
end.uniq
|
|
end
|
|
|
|
# Checks the Git tags for new versions. When a regex isn't provided,
|
|
# this strategy simply removes non-digits from the start of tag
|
|
# strings and parses the remaining text as a {Version}.
|
|
#
|
|
# @param url [String] the URL of the Git repository to check
|
|
# @param regex [Regexp, nil] a regex used for matching versions
|
|
# @param options [Options] options to modify behavior
|
|
# @return [Hash]
|
|
sig {
|
|
params(
|
|
url: String,
|
|
regex: T.nilable(Regexp),
|
|
options: Options,
|
|
block: T.nilable(Proc),
|
|
).returns(T::Hash[Symbol, T.untyped])
|
|
}
|
|
def self.find_versions(url:, regex: nil, options: Options.new, &block)
|
|
match_data = { matches: {}, regex:, url: }
|
|
|
|
tags_data = tag_info(url, regex)
|
|
tags = tags_data[:tags]
|
|
|
|
if tags_data.key?(:messages)
|
|
match_data[:messages] = tags_data[:messages]
|
|
return match_data if tags.blank?
|
|
end
|
|
|
|
versions_from_tags(tags, regex, &block).each do |version_text|
|
|
match_data[:matches][version_text] = Version.new(version_text)
|
|
rescue TypeError
|
|
next
|
|
end
|
|
|
|
match_data
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|