From 7aa9c4d8b8c5de200eaab3bd4de5e2068c6261f3 Mon Sep 17 00:00:00 2001 From: hyuraku <32809703+hyuraku@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:36:56 +0900 Subject: [PATCH 1/2] Move `Vulns::Semver` to `Homebrew::Semver` --- Library/Homebrew/semver.rb | 101 +++++++++++++++++ .../Homebrew/test/{vulns => }/semver_spec.rb | 4 +- .../Homebrew/test/vulns/vulnerability_spec.rb | 2 +- Library/Homebrew/vulns.rb | 2 +- Library/Homebrew/vulns/semver.rb | 103 ------------------ Library/Homebrew/vulns/vulnerability.rb | 2 +- 6 files changed, 106 insertions(+), 108 deletions(-) create mode 100644 Library/Homebrew/semver.rb rename Library/Homebrew/test/{vulns => }/semver_spec.rb (99%) delete mode 100644 Library/Homebrew/vulns/semver.rb diff --git a/Library/Homebrew/semver.rb b/Library/Homebrew/semver.rb new file mode 100644 index 0000000000000..85b9613628f07 --- /dev/null +++ b/Library/Homebrew/semver.rb @@ -0,0 +1,101 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + # SemVer 2.0 comparison for OSV `SEMVER` ranges (https://semver.org/#spec-item-11). + # Kept separate from `::Version`, whose ordering differs for prerelease and + # build metadata. Minor/patch may be omitted; other spec violations or + # inputs over 256 bytes return `nil`. + module Semver + MAX_LENGTH = 256 + private_constant :MAX_LENGTH + + CORE_SEGMENT = "(0|[1-9]\\d*)" + private_constant :CORE_SEGMENT + + # A numeric identifier without a leading zero, or an alphanumeric + # identifier (which may start with any digit). + PRERELEASE_IDENTIFIER = "(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)" + private_constant :PRERELEASE_IDENTIFIER + + BUILD_IDENTIFIER = "[0-9A-Za-z-]+" + private_constant :BUILD_IDENTIFIER + + SEMVER_REGEX = / + \A + #{CORE_SEGMENT}(?:\.#{CORE_SEGMENT})?(?:\.#{CORE_SEGMENT})? + (?:-(#{PRERELEASE_IDENTIFIER}(?:\.#{PRERELEASE_IDENTIFIER})*))? + (?:\+#{BUILD_IDENTIFIER}(?:\.#{BUILD_IDENTIFIER})*)? + \z + /x + private_constant :SEMVER_REGEX + + NUMERIC_IDENTIFIER = /\A\d+\z/ + private_constant :NUMERIC_IDENTIFIER + + sig { params(left: String, right: String).returns(T.nilable(Integer)) } + def self.compare(left, right) + a = parse(left) + b = parse(right) + return if a.nil? || b.nil? + + core = a.fetch(:core) <=> b.fetch(:core) + return core unless core.zero? + + compare_prerelease(a.fetch(:prerelease), b.fetch(:prerelease)) + end + + sig { params(version: String).returns(T.nilable(String)) } + def self.release_version(version) + parsed = parse(version) + return if parsed.nil? || parsed.fetch(:prerelease).empty? + + parsed.fetch(:core).join(".") + end + + sig { params(version: String).returns(T.nilable({ core: [Integer, Integer, Integer], prerelease: T::Array[String] })) } + private_class_method def self.parse(version) + return if version.bytesize > MAX_LENGTH + + match = version.strip.sub(/\Av/i, "").match(SEMVER_REGEX) + return if match.nil? + + { + core: [match[1].to_i, match[2].to_i, match[3].to_i], + prerelease: match[4]&.split(".") || [], + } + end + + sig { params(left: T::Array[String], right: T::Array[String]).returns(Integer) } + private_class_method def self.compare_prerelease(left, right) + return 0 if left.empty? && right.empty? + return 1 if left.empty? + return -1 if right.empty? + + left.zip(right) do |lhs, rhs| + return 1 if rhs.nil? + + cmp = compare_identifier(lhs, rhs) + return cmp unless cmp.zero? + end + (left.length == right.length) ? 0 : -1 + end + + sig { params(lhs: String, rhs: String).returns(Integer) } + private_class_method def self.compare_identifier(lhs, rhs) + lhs_numeric = lhs.match?(NUMERIC_IDENTIFIER) + rhs_numeric = rhs.match?(NUMERIC_IDENTIFIER) + + # spec 11.4.3: numeric identifiers sort below alphanumeric + return -1 if lhs_numeric && !rhs_numeric + return 1 if !lhs_numeric && rhs_numeric + + return lhs.to_i <=> rhs.to_i if lhs_numeric + + comparison = lhs <=> rhs + raise ArgumentError, "Cannot compare #{lhs.inspect} with #{rhs.inspect}" if comparison.nil? + + comparison + end + end +end diff --git a/Library/Homebrew/test/vulns/semver_spec.rb b/Library/Homebrew/test/semver_spec.rb similarity index 99% rename from Library/Homebrew/test/vulns/semver_spec.rb rename to Library/Homebrew/test/semver_spec.rb index a53bd37ed16f2..b0e8cbb567154 100644 --- a/Library/Homebrew/test/vulns/semver_spec.rb +++ b/Library/Homebrew/test/semver_spec.rb @@ -1,9 +1,9 @@ # typed: strict # frozen_string_literal: true -require "vulns/semver" +require "semver" -RSpec.describe Homebrew::Vulns::Semver do +RSpec.describe Homebrew::Semver do describe ".release_version" do it "rejects repeated version prefixes" do expect(%w[vv vV Vv VV].map { |prefix| described_class.release_version("#{prefix}1.0.0-rc.1") }) diff --git a/Library/Homebrew/test/vulns/vulnerability_spec.rb b/Library/Homebrew/test/vulns/vulnerability_spec.rb index 0447d9b587f1c..d5a1211f3c6fb 100644 --- a/Library/Homebrew/test/vulns/vulnerability_spec.rb +++ b/Library/Homebrew/test/vulns/vulnerability_spec.rb @@ -593,7 +593,7 @@ def range(type, *events) it "fails closed when SEMVER comparison raises" do v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "1.0.0" }, { "fixed" => "1.5.0" }))) - allow(Homebrew::Vulns::Semver).to receive(:compare).and_raise(StandardError, "boom") + allow(Homebrew::Semver).to receive(:compare).and_raise(StandardError, "boom") expect(v.affects_version?("1.2.0")).to be true end diff --git a/Library/Homebrew/vulns.rb b/Library/Homebrew/vulns.rb index 11c9459d5a495..51675d59b8262 100644 --- a/Library/Homebrew/vulns.rb +++ b/Library/Homebrew/vulns.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -require "vulns/semver" +require "semver" require "vulns/cvss" require "vulns/vulnerability" require "vulns/osv" diff --git a/Library/Homebrew/vulns/semver.rb b/Library/Homebrew/vulns/semver.rb deleted file mode 100644 index 59e234632bf8f..0000000000000 --- a/Library/Homebrew/vulns/semver.rb +++ /dev/null @@ -1,103 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -module Homebrew - module Vulns - # SemVer 2.0 comparison for OSV `SEMVER` ranges (https://semver.org/#spec-item-11). - # Kept separate from `::Version`, whose ordering differs for prerelease and - # build metadata. Minor/patch may be omitted; other spec violations or - # inputs over 256 bytes return `nil`. - module Semver - MAX_LENGTH = 256 - private_constant :MAX_LENGTH - - CORE_SEGMENT = "(0|[1-9]\\d*)" - private_constant :CORE_SEGMENT - - # A numeric identifier without a leading zero, or an alphanumeric - # identifier (which may start with any digit). - PRERELEASE_IDENTIFIER = "(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)" - private_constant :PRERELEASE_IDENTIFIER - - BUILD_IDENTIFIER = "[0-9A-Za-z-]+" - private_constant :BUILD_IDENTIFIER - - SEMVER_REGEX = / - \A - #{CORE_SEGMENT}(?:\.#{CORE_SEGMENT})?(?:\.#{CORE_SEGMENT})? - (?:-(#{PRERELEASE_IDENTIFIER}(?:\.#{PRERELEASE_IDENTIFIER})*))? - (?:\+#{BUILD_IDENTIFIER}(?:\.#{BUILD_IDENTIFIER})*)? - \z - /x - private_constant :SEMVER_REGEX - - NUMERIC_IDENTIFIER = /\A\d+\z/ - private_constant :NUMERIC_IDENTIFIER - - sig { params(left: String, right: String).returns(T.nilable(Integer)) } - def self.compare(left, right) - a = parse(left) - b = parse(right) - return if a.nil? || b.nil? - - core = a.fetch(:core) <=> b.fetch(:core) - return core unless core.zero? - - compare_prerelease(a.fetch(:prerelease), b.fetch(:prerelease)) - end - - sig { params(version: String).returns(T.nilable(String)) } - def self.release_version(version) - parsed = parse(version) - return if parsed.nil? || parsed.fetch(:prerelease).empty? - - parsed.fetch(:core).join(".") - end - - sig { params(version: String).returns(T.nilable({ core: [Integer, Integer, Integer], prerelease: T::Array[String] })) } - private_class_method def self.parse(version) - return if version.bytesize > MAX_LENGTH - - match = version.strip.sub(/\Av/i, "").match(SEMVER_REGEX) - return if match.nil? - - { - core: [match[1].to_i, match[2].to_i, match[3].to_i], - prerelease: match[4]&.split(".") || [], - } - end - - sig { params(left: T::Array[String], right: T::Array[String]).returns(Integer) } - private_class_method def self.compare_prerelease(left, right) - return 0 if left.empty? && right.empty? - return 1 if left.empty? - return -1 if right.empty? - - left.zip(right) do |lhs, rhs| - return 1 if rhs.nil? - - cmp = compare_identifier(lhs, rhs) - return cmp unless cmp.zero? - end - (left.length == right.length) ? 0 : -1 - end - - sig { params(lhs: String, rhs: String).returns(Integer) } - private_class_method def self.compare_identifier(lhs, rhs) - lhs_numeric = lhs.match?(NUMERIC_IDENTIFIER) - rhs_numeric = rhs.match?(NUMERIC_IDENTIFIER) - - # spec 11.4.3: numeric identifiers sort below alphanumeric - return -1 if lhs_numeric && !rhs_numeric - return 1 if !lhs_numeric && rhs_numeric - - return lhs.to_i <=> rhs.to_i if lhs_numeric - - comparison = lhs <=> rhs - raise ArgumentError, "Cannot compare #{lhs.inspect} with #{rhs.inspect}" if comparison.nil? - - comparison - end - end - end -end diff --git a/Library/Homebrew/vulns/vulnerability.rb b/Library/Homebrew/vulns/vulnerability.rb index 4fe26953578ba..843f70ed5e618 100644 --- a/Library/Homebrew/vulns/vulnerability.rb +++ b/Library/Homebrew/vulns/vulnerability.rb @@ -4,7 +4,7 @@ require "utils/output" require "vulns/cvss" -require "vulns/semver" +require "semver" module Homebrew module Vulns From 94e3e9d8af239249c44cbe6f1cbda3cad60b2129 Mon Sep 17 00:00:00 2001 From: hyuraku <32809703+hyuraku@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:44:35 +0900 Subject: [PATCH 2/2] bump: order npm cooldown versions by semver --- Library/Homebrew/dev-cmd/bump.rb | 33 +++++++--- Library/Homebrew/semver.rb | 8 +++ Library/Homebrew/test/dev-cmd/bump_spec.rb | 76 ++++++++++++++++++++++ Library/Homebrew/test/semver_spec.rb | 12 ++++ 4 files changed, 120 insertions(+), 9 deletions(-) diff --git a/Library/Homebrew/dev-cmd/bump.rb b/Library/Homebrew/dev-cmd/bump.rb index 38343e8bc0fd6..de453d8ed56c8 100644 --- a/Library/Homebrew/dev-cmd/bump.rb +++ b/Library/Homebrew/dev-cmd/bump.rb @@ -6,6 +6,7 @@ require "bump_version_parser" require "livecheck/livecheck" require "release_cooldown" +require "semver" require "utils/curl" require "utils/repology" @@ -695,6 +696,13 @@ def message?(value) value.match?(LIVECHECK_MESSAGE_REGEX) end + sig { params(strategy: T.nilable(String), left: String, right: String).returns(T.nilable(Integer)) } + def compare_versions_for_strategy(strategy, left, right) + return Semver.compare(left, right) if strategy == "Npm" + + Version.new(left) <=> Version.new(right) + end + # Identifies the highest upstream version that has been released before # the cooldown interval. # @@ -711,9 +719,10 @@ def version_with_cooldown(version_info, current = nil) 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)) + return unless compare_versions_for_strategy(strategy, latest.to_s, current.to_s)&.positive? + case strategy when "Npm" url = version_info.dig(:meta, :url, :strategy)&.delete_suffix("/latest") @@ -729,17 +738,20 @@ def version_with_cooldown(version_info, current = nil) return unless release_dates.present? current_str = current.to_s - current_is_prerelease = current_str.include?("-") + latest_str = latest.to_s + current_is_prerelease = Semver.prerelease?(current_str) cooldown_interval = (DateTime.now - Homebrew::RELEASE_COOLDOWN_DAYS) release_dates.sort_by { |_, date| date }.reverse_each do |version_str, date| - version = Version.new(version_str) - return version if version_str == current_str - next if (version > latest) || (version < current) + return Version.new(version_str) if version_str == current_str - # TODO: Properly handle prerelease version comparison - next if !current_is_prerelease && version_str.include?("-") + latest_comparison = compare_versions_for_strategy(strategy, version_str, latest_str) + next if latest_comparison.nil? || latest_comparison.positive? - return version if date < cooldown_interval + current_comparison = compare_versions_for_strategy(strategy, version_str, current_str) + next if current_comparison.nil? || current_comparison.negative? + next if !current_is_prerelease && Semver.prerelease?(version_str) + + return Version.new(version_str) if date < cooldown_interval end when "Pypi" url = version_info.dig(:meta, :url, :strategy) @@ -981,7 +993,10 @@ def livecheck_result(formula_or_cask, current) if !version_info.key?(:latest_throttled) latest = Version.new(version_info[:latest]) cooldown_version = version_with_cooldown(version_info, current) - cooldown_skipped = (latest if cooldown_version && cooldown_version < latest) + cooldown_skipped = if cooldown_version + strategy = version_info.dig(:meta, :strategy) + latest if compare_versions_for_strategy(strategy, cooldown_version.to_s, latest.to_s)&.negative? + end [cooldown_version || latest, cooldown_skipped] elsif version_info[:latest_throttled].nil? ["unable to get throttled versions", nil] diff --git a/Library/Homebrew/semver.rb b/Library/Homebrew/semver.rb index 85b9613628f07..f422539760bd0 100644 --- a/Library/Homebrew/semver.rb +++ b/Library/Homebrew/semver.rb @@ -45,6 +45,14 @@ def self.compare(left, right) compare_prerelease(a.fetch(:prerelease), b.fetch(:prerelease)) end + sig { params(version: String).returns(T::Boolean) } + def self.prerelease?(version) + parsed = parse(version) + return false if parsed.nil? + + parsed.fetch(:prerelease).any? + end + sig { params(version: String).returns(T.nilable(String)) } def self.release_version(version) parsed = parse(version) diff --git a/Library/Homebrew/test/dev-cmd/bump_spec.rb b/Library/Homebrew/test/dev-cmd/bump_spec.rb index 862f0316ffab5..dfeab40f1e4d5 100644 --- a/Library/Homebrew/test/dev-cmd/bump_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bump_spec.rb @@ -128,6 +128,37 @@ class PartiallyDisabledOsFormula < Formula it_behaves_like "parseable arguments" + def npm_version_info(latest) + { + latest:, + meta: { + strategy: "Npm", + url: { + strategy: "https://registry.npmjs.org/example-package/latest", + }, + }, + } + end + + def stub_npm_registry(release_dates) + content = { "time" => release_dates.merge("created" => "2026-02-01T00:00:00.000Z") }.to_json + allow(Utils::Curl).to receive(:curl_output) + .with( + "--compressed", + "--fail-with-body", + "--location", + "--max-redirs", + "5", + "--silent", + "https://registry.npmjs.org/example-package", + connect_timeout: 15, + max_time: 55, + retries: 0, + timeout: 60, + ) + .and_return([content, "", instance_double(Process::Status, success?: true)]) + end + describe "formula and cask", :cask, :integration_test do it "prints messages for HEAD-only Formulae and latest Casks" do content = <<~RUBY @@ -488,6 +519,28 @@ class PartiallyDisabledOsFormula < Formula expect(version_info.cooldown_skipped_versions).to eq({ general: Version.new("1.2.4") }) end + it "records the npm release skipped due to cooldown when a prerelease is chosen" do + f_prerelease = formula("prerelease_formula") do + T.bind(self, T.class_of(Formula)) + desc "Prerelease formula" + url "https://brew.sh/test-1.2.3-next.1.tgz" + version "1.2.3-next.1" + end + allow(Homebrew::Livecheck::SkipConditions).to receive(:skip_information).and_return({}) + allow(Homebrew::Livecheck).to receive(:latest_version).and_return(npm_version_info("1.2.4")) + allow(DateTime).to receive(:now).and_return(DateTime.parse("2026-04-04T12:00:00Z")) + stub_npm_registry( + "1.2.3-next.1" => "2026-02-01T00:00:00.000Z", + "1.2.4-next.2" => "2026-03-01T00:00:00.000Z", + "1.2.4" => "2026-04-04T00:00:00.000Z", + ) + + version_info = bump.retrieve_versions_by_arch( + formula_or_cask: f_prerelease, repositories: [], name: "prerelease_formula", + ) + expect(version_info.cooldown_skipped_versions).to eq({ general: Version.new("1.2.4") }) + end + it "records cooldown-skipped versions per architecture" do allow(c_multi_arch).to receive(:sourcefile_path).and_return(Pathname("multi_arch_cask.rb")) allow(Cask::CaskLoader).to receive(:load).and_return(c_multi_arch) @@ -715,6 +768,29 @@ class PartiallyDisabledOsFormula < Formula expect(bump.version_with_cooldown(version_info, Version.new("1.2.2"))).to eq(Version.new("1.2.3")) end + + it "uses semver precedence for npm prerelease versions" do + allow(DateTime).to receive(:now).and_return(DateTime.parse("2026-04-04T12:00:00Z")) + stub_npm_registry( + "1.2.3-next.1" => "2026-02-01T00:00:00.000Z", + "1.2.4-next.2" => "2026-03-01T00:00:00.000Z", + "1.2.4" => "2026-04-04T00:00:00.000Z", + ) + + expect(bump.version_with_cooldown(npm_version_info("1.2.4"), Version.new("1.2.3-next.1"))) + .to eq(Version.new("1.2.4-next.2")) + end + + it "checks the npm cooldown when the current version is a prerelease of the latest" do + allow(DateTime).to receive(:now).and_return(DateTime.parse("2026-04-04T12:00:00Z")) + stub_npm_registry( + "1.2.3-next.1" => "2026-02-01T00:00:00.000Z", + "1.2.3" => "2026-04-02T00:00:00.000Z", + ) + + expect(bump.version_with_cooldown(npm_version_info("1.2.3"), Version.new("1.2.3-next.1"))) + .to eq(Version.new("1.2.3")) + end end describe "::retrieve_pull_requests" do diff --git a/Library/Homebrew/test/semver_spec.rb b/Library/Homebrew/test/semver_spec.rb index b0e8cbb567154..ecbcd5b4d5ce5 100644 --- a/Library/Homebrew/test/semver_spec.rb +++ b/Library/Homebrew/test/semver_spec.rb @@ -25,6 +25,18 @@ end end + describe ".prerelease?" do + it "detects a prerelease with a prefix and build metadata" do + expect(described_class.prerelease?("v2026.2.22-rc.1+build.2")).to be true + end + + it "returns false for releases, metadata-only suffixes and invalid versions" do + expect(["2026.2.22", "2026.2.22+build-2", "not-a-version"].map do |version| + described_class.prerelease?(version) + end).to eq [false, false, false] + end + end + describe ".compare" do it "rejects repeated version prefixes on either side" do expect(%w[vv vV Vv VV].flat_map do |prefix|