Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions Library/Homebrew/dev-cmd/bump.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "bump_version_parser"
require "livecheck/livecheck"
require "release_cooldown"
require "semver"
require "utils/curl"
require "utils/repology"

Expand Down Expand Up @@ -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.
#
Expand All @@ -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")
Expand All @@ -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
Comment thread
MikeMcQuaid marked this conversation as resolved.
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)
Expand Down Expand Up @@ -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]
Expand Down
109 changes: 109 additions & 0 deletions Library/Homebrew/semver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# 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::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)
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
76 changes: 76 additions & 0 deletions Library/Homebrew/test/dev-cmd/bump_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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") })
Expand All @@ -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|
Expand Down
2 changes: 1 addition & 1 deletion Library/Homebrew/test/vulns/vulnerability_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Library/Homebrew/vulns.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# typed: strict
# frozen_string_literal: true

require "vulns/semver"
require "semver"
require "vulns/cvss"
require "vulns/vulnerability"
require "vulns/osv"
Expand Down
Loading
Loading