From 0950c01926ae26a5dd4fc1e2fc8a4773f6e43b4a Mon Sep 17 00:00:00 2001 From: Sam Ford <1584702+samford@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:35:40 -0400 Subject: [PATCH 1/3] bump: add cooldown for npm and PyPI This implements Mike's idea to add a cooldown to `brew bump` for npm and PyPI packages, in light of ongoing security incidents in npm in particular. The `version_with_cooldown` method checks upstream sources for version and release date information and identifies the highest version that was released before the cooldown interval. This works based on very limited manual testing but there are some caveats: * The keys in the `releases` field in the PyPI JSON are sorted using string comparison (e.g., 1.2.30 is latest but 1.2.4, 1.2.5, etc. are after it), so this sorts using `Version` comparison before reverse iterating to find the highest suitable version. This works when the package uses a typical version scheme but I'm not sure if this will work as expected for all packages, so I'll have to do more testing. * npm packages can contain a variety of different version streams (e.g., dev, legacy, stable) with releases interleaved. As with the PyPI approach, this sorts using `Version` comparison before reverse iterating. Depending on how upstream handles versions, it may be possible for this approach to pick an unstable version, so this is something that I may need to rework. * npm packages with thousands of releases will have a JSON response that's several MB and this takes a notable amount of time to download and parse (e.g., `wrangler` is ~30 MB and takes ~30 seconds). This comes into play whenever livecheck surfaces a new version, so ideally we would cache the response etag and JSON data between bump runs to allow us to use `If-None-Match` in requests and avoid unnecessary downloads (like `npm` and `pip`). This is especially an issue for packages with an aggressive release cadence, where there may always be a new version available due to the cooldown interval. However, those may be good candidates to use `throttle` instead. This still needs tests but it works as a proof of concept at this stage. --- Library/Homebrew/dev-cmd/bump.rb | 105 +++++++++++++++++++++++- Library/Homebrew/livecheck/livecheck.rb | 2 + 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/dev-cmd/bump.rb b/Library/Homebrew/dev-cmd/bump.rb index 70db0a84adc7b..3e03d0ac4eaec 100644 --- a/Library/Homebrew/dev-cmd/bump.rb +++ b/Library/Homebrew/dev-cmd/bump.rb @@ -4,11 +4,28 @@ require "abstract_command" require "bump_version_parser" require "livecheck/livecheck" +require "utils/curl" require "utils/repology" module Homebrew module DevCmd class Bump < AbstractCommand + MIN_RELEASE_AGE_DAYS = 1 + DEFAULT_CURL_ARGS = T.let([ + "--compressed", + "--fail-with-body", + "--location", + "--max-redirs", + "5", + "--silent", + ].freeze, T::Array[String]) + DEFAULT_CURL_OPTIONS = T.let({ + connect_timeout: 15, + max_time: 55, + timeout: 60, + retries: 0, + }.freeze, T::Hash[Symbol, T.untyped]) + LIVECHECK_MESSAGE_REGEX = /^(?:error:|skipped|unable to get(?: throttled)? versions)/i NEWER_THAN_UPSTREAM_MSG = " (newer than upstream)" @@ -250,9 +267,12 @@ def skip_ineligible_formulae!(formula_or_cask) end sig { - params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T.any(Version, String)) + params( + formula_or_cask: T.any(Formula, Cask::Cask), + current: T.nilable(T.any(Version, Cask::DSL::Version)), + ).returns(T.any(Version, String)) } - def livecheck_result(formula_or_cask) + def livecheck_result(formula_or_cask, current) name = Livecheck.package_or_resource_name(formula_or_cask) referenced_formula_or_cask, = Livecheck.resolve_livecheck_reference( @@ -294,7 +314,7 @@ def livecheck_result(formula_or_cask) return "unable to get versions" if version_info.blank? if !version_info.key?(:latest_throttled) - Version.new(version_info[:latest]) + version_with_cooldown(version_info, current) || Version.new(version_info[:latest]) elsif version_info[:latest_throttled].nil? "unable to get throttled versions" else @@ -367,7 +387,7 @@ def retrieve_versions_by_arch(formula_or_cask:, repositories:, name:) deprecated[version_key] = loaded_formula_or_cask.deprecated? formula_or_cask_has_livecheck = loaded_formula_or_cask.livecheck_defined? - livecheck_latest = livecheck_result(loaded_formula_or_cask) + livecheck_latest = livecheck_result(loaded_formula_or_cask, current_version_value) livecheck_latest_is_a_version = livecheck_latest.is_a?(Version) new_version_value = if (livecheck_latest_is_a_version && @@ -840,6 +860,83 @@ def autobumped_formulae_or_casks(tap, casks: false) end end end + + # Identifies the highest upstream version that has been released before + # the cooldown interval. + # + # @param version_info the return hash from `Livecheck.latest_version` + # @param current the current version + sig { + params( + version_info: T::Hash[Symbol, T.untyped], + current: T.nilable(T.any(Version, Cask::DSL::Version)), + ).returns(T.nilable(Version)) + } + def version_with_cooldown(version_info, current = nil) + return unless current + + latest = Version.new(version_info[:latest]) if version_info[:latest] + return unless latest + return if latest <= current + + strategy = T.cast(version_info.dig(:meta, :strategy), T.nilable(String)) + case strategy + when "Npm" + url = version_info.dig(:meta, :url, :strategy)&.delete_suffix("/latest") + return unless url + + stdout, _stderr, status = Utils::Curl.curl_output(*DEFAULT_CURL_ARGS, url, **DEFAULT_CURL_OPTIONS) + return unless status.success? + return if (content = stdout.scrub).blank? + + json = Homebrew::Livecheck::Strategy::Json.parse_json(content) + release_dates = json["time"]&.except("created", "modified") + return unless release_dates.present? + + cooldown_interval = (DateTime.now - MIN_RELEASE_AGE_DAYS) + release_dates.sort_by { |k, _| Version.new(k) }.reverse_each do |version_str, date_str| + version = Version.new(version_str) + return version if version_str == current.to_s + + date = DateTime.parse(date_str) + return version if date < cooldown_interval + end + when "Pypi" + url = version_info.dig(:meta, :url, :strategy) + original_url = version_info.dig(:meta, :url, :original) + return if !url || !original_url + + suffix = Homebrew::Livecheck::Strategy::Pypi::URL_MATCH_REGEX.match(original_url)&.[](:suffix) + return unless suffix + + content = version_info[:content] + unless content + stdout, _stderr, status = Utils::Curl.curl_output(*DEFAULT_CURL_ARGS, url, **DEFAULT_CURL_OPTIONS) + return unless status.success? + + content = stdout.scrub + end + return if content.blank? + + json = Homebrew::Livecheck::Strategy::Json.parse_json(content) + return unless (releases = json["releases"]) + + cooldown_interval = (DateTime.now - MIN_RELEASE_AGE_DAYS) + releases.sort_by { |k, _| Version.new(k) }.reverse_each do |version_str, assets| + version = Version.new(version_str) + return version if version_str == current.to_s + + assets.each do |asset| + next if asset["yanked"] + next unless asset["url"]&.end_with?(suffix) + next unless (date_str = asset["upload_time_iso_8601"]) + + date = DateTime.parse(date_str) + return version if date < cooldown_interval + end + end + end + end end end end diff --git a/Library/Homebrew/livecheck/livecheck.rb b/Library/Homebrew/livecheck/livecheck.rb index 1d76fefe3e5df..5fddc23b06c78 100644 --- a/Library/Homebrew/livecheck/livecheck.rb +++ b/Library/Homebrew/livecheck/livecheck.rb @@ -856,6 +856,8 @@ def self.latest_version( version_info[:meta][:cached] = true if strategy_data[:cached] == true version_info[:meta][:throttle] = livecheck_throttle if livecheck_throttle version_info[:meta][:throttle_days] = livecheck_throttle_days if livecheck_throttle_days + + version_info[:content] = strategy_data[:content] if strategy_data[:content] && strategy_name == "Pypi" end return version_info From 0d77797983c781eb92bd52684f00829b3a33e960 Mon Sep 17 00:00:00 2001 From: Sam Ford <1584702+samford@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:57:21 -0400 Subject: [PATCH 2/3] bump: filter npm cooldown versions This adds some additional logic to npm cooldown handling to ensure that we're only checking versions that are between the current and latest versions. This also skips versions that include a hyphen if the current version doesn't include a hyphen, as a very naive way of skipping prerelease versions if the current version is stable. I've only done basic testing of this and there may be outliers but it may handle simple scenarios, at least. --- Library/Homebrew/dev-cmd/bump.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Library/Homebrew/dev-cmd/bump.rb b/Library/Homebrew/dev-cmd/bump.rb index 3e03d0ac4eaec..4be5ab6d9a738 100644 --- a/Library/Homebrew/dev-cmd/bump.rb +++ b/Library/Homebrew/dev-cmd/bump.rb @@ -891,14 +891,20 @@ def version_with_cooldown(version_info, current = nil) json = Homebrew::Livecheck::Strategy::Json.parse_json(content) release_dates = json["time"]&.except("created", "modified") + &.transform_values { |v| DateTime.parse(v) } return unless release_dates.present? + current_str = current.to_s + current_is_prerelease = current_str.include?("-") cooldown_interval = (DateTime.now - MIN_RELEASE_AGE_DAYS) - release_dates.sort_by { |k, _| Version.new(k) }.reverse_each do |version_str, date_str| + release_dates.sort_by { |_, date| date }.reverse_each do |version_str, date| version = Version.new(version_str) - return version if version_str == current.to_s + return version if version_str == current_str + next if (version > latest) || (version < current) + + # TODO: Properly handle prerelease version comparison + next if !current_is_prerelease && version_str.include?("-") - date = DateTime.parse(date_str) return version if date < cooldown_interval end when "Pypi" From 17ad1549fd46007c6f4172ddc5b10f4f6f5246b1 Mon Sep 17 00:00:00 2001 From: Sam Ford <1584702+samford@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:15:58 -0400 Subject: [PATCH 3/3] bump: filter PyPI cooldown versions This copies the guard from the npm logic, where we filter out versions that aren't between current and latest. This also skips pre-release versions if the current version isn't a pre-release version, referencing the version format specified in the Python Packaging User Guide. --- Library/Homebrew/dev-cmd/bump.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Library/Homebrew/dev-cmd/bump.rb b/Library/Homebrew/dev-cmd/bump.rb index 4be5ab6d9a738..0156b291f68b2 100644 --- a/Library/Homebrew/dev-cmd/bump.rb +++ b/Library/Homebrew/dev-cmd/bump.rb @@ -25,6 +25,7 @@ class Bump < AbstractCommand timeout: 60, retries: 0, }.freeze, T::Hash[Symbol, T.untyped]) + PYPI_UNSTABLE_VERSION_REGEX = /^(?:\d+!)?\d+(?:\.\d+)*(?:a|b|rc)\d+|\.dev\d+$/i LIVECHECK_MESSAGE_REGEX = /^(?:error:|skipped|unable to get(?: throttled)? versions)/i NEWER_THAN_UPSTREAM_MSG = " (newer than upstream)" @@ -927,10 +928,14 @@ def version_with_cooldown(version_info, current = nil) json = Homebrew::Livecheck::Strategy::Json.parse_json(content) return unless (releases = json["releases"]) + current_str = current.to_s + current_is_prerelease = current_str.match?(PYPI_UNSTABLE_VERSION_REGEX) cooldown_interval = (DateTime.now - MIN_RELEASE_AGE_DAYS) releases.sort_by { |k, _| Version.new(k) }.reverse_each do |version_str, assets| version = Version.new(version_str) - return version if version_str == current.to_s + return version if version_str == current_str + next if (version > latest) || (version < current) + next if !current_is_prerelease && version_str.match?(PYPI_UNSTABLE_VERSION_REGEX) assets.each do |asset| next if asset["yanked"]