Skip to content

Commit 27ac8a7

Browse files
committed
Add minimum version gating
- Let automation ask whether one named package is below a fleet policy floor without needing the tap's latest version to carry that meaning. - Keep the threshold tied to exactly one explicit name so ambiguous package sets cannot share a policy value by accident. - Share comparison logic across `outdated` and `upgrade` so formulae and casks use Homebrew's version semantics consistently. - Report minimum-version skips distinctly from truly current formulae so policy-gated upgrades do not imply latest is installed. - Reject invalid cask minimum versions because silent skips would hide automation configuration mistakes. - Expose `--min-version` beside `--minimum-version` because existing callers may need the shorter spelling during rollout.
1 parent 6c8be83 commit 27ac8a7

7 files changed

Lines changed: 360 additions & 13 deletions

File tree

Library/Homebrew/cmd/outdated.rb

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "formula"
66
require "cask/caskroom"
77
require "api"
8+
require "minimum_version"
89

910
module Homebrew
1011
module Cmd
@@ -26,6 +27,9 @@ class Outdated < AbstractCommand
2627
description: "Print output in JSON format. There are two versions: `v1` and `v2`. " \
2728
"`v1` is deprecated and is currently the default if no version is specified. " \
2829
"`v2` prints outdated formulae and casks."
30+
flag "--minimum-version=", "--min-version=",
31+
description: "Only list a named formula or cask with an installed version below the given " \
32+
"minimum version."
2933
switch "--fetch-HEAD",
3034
description: "Fetch the upstream repository to detect if the HEAD installation of the " \
3135
"formula is outdated. Otherwise, the repository's HEAD will only be checked for " \
@@ -47,6 +51,9 @@ class Outdated < AbstractCommand
4751

4852
sig { override.void }
4953
def run
54+
raise UsageError, "`--minimum-version` requires exactly one formula or cask argument." if
55+
minimum_version.present? && args.named.length != 1
56+
5057
case json_version(args.json)
5158
when :v1
5259
odie "`brew outdated --json=v1` is no longer supported. Use brew outdated --json=v2 instead."
@@ -92,10 +99,12 @@ def print_outdated(formulae_or_casks)
9299
f = formula_or_cask
93100

94101
if verbose?
95-
outdated_kegs = f.outdated_kegs(fetch_head: args.fetch_HEAD?)
102+
outdated_kegs = formula_outdated_kegs(f)
96103
latest_formula = f.latest_formula
97104

98-
current_version = if f.alias_changed? && !latest_formula.latest_version_installed?
105+
current_version = if minimum_version.present?
106+
minimum_version
107+
elsif f.alias_changed? && !latest_formula.latest_version_installed?
99108
"#{latest_formula.name} (#{latest_formula.pkg_version})"
100109
elsif f.head?
101110
latest_head_version = f.latest_head_pkg_version(fetch_head: args.fetch_HEAD?)
@@ -124,8 +133,18 @@ def print_outdated(formulae_or_casks)
124133
else
125134
c = formula_or_cask
126135

127-
puts c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask), verbose?,
128-
false, args.greedy_latest?, args.greedy_auto_updates?)
136+
if minimum_version.present?
137+
if verbose?
138+
pinned_version = " [pinned at #{c.pinned_version}]" if c.pinned?
139+
140+
puts "#{c.token} (#{c.installed_version}) < #{minimum_version}#{pinned_version}"
141+
else
142+
puts c.token
143+
end
144+
else
145+
puts c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask), verbose?,
146+
false, args.greedy_latest?, args.greedy_auto_updates?)
147+
end
129148
end
130149
end
131150
end
@@ -140,8 +159,10 @@ def json_info(formulae_or_casks)
140159
if formula_or_cask.is_a?(Formula)
141160
f = formula_or_cask
142161

143-
outdated_versions = f.outdated_kegs(fetch_head: args.fetch_HEAD?).map(&:version)
144-
current_version = if f.head? && outdated_versions.any? { |v| v.to_s == f.pkg_version.to_s }
162+
outdated_versions = formula_outdated_kegs(f).map(&:version)
163+
current_version = if minimum_version.present?
164+
minimum_version
165+
elsif f.head? && outdated_versions.any? { |v| v.to_s == f.pkg_version.to_s }
145166
"HEAD"
146167
else
147168
f.pkg_version.to_s
@@ -155,11 +176,19 @@ def json_info(formulae_or_casks)
155176
else
156177
c = formula_or_cask
157178

158-
T.cast(
159-
c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask),
160-
verbose?, true, args.greedy_latest?, args.greedy_auto_updates?),
161-
T::Hash[Symbol, T.untyped],
162-
)
179+
if minimum_version.present?
180+
{ name: c.token,
181+
installed_versions: [T.must(c.installed_version)],
182+
current_version: T.must(minimum_version),
183+
pinned: c.pinned?,
184+
pinned_version: c.pinned_version }
185+
else
186+
T.cast(
187+
c.outdated_info(upgrade_greedy_cask?(args.greedy?, formula_or_cask),
188+
verbose?, true, args.greedy_latest?, args.greedy_auto_updates?),
189+
T::Hash[Symbol, T.untyped],
190+
)
191+
end
163192
end
164193
end
165194
end
@@ -180,6 +209,9 @@ def json_version(version)
180209
version_hash.fetch(version) { raise UsageError, "invalid JSON version: #{version}" }
181210
end
182211

212+
sig { returns(T.nilable(String)) }
213+
def minimum_version = args.minimum_version || args.min_version
214+
183215
sig { returns(T::Array[Formula]) }
184216
def outdated_formulae
185217
T.cast(
@@ -217,8 +249,16 @@ def outdated_formulae_casks
217249
def select_outdated(formulae_or_casks)
218250
formulae_or_casks.select do |formula_or_cask|
219251
if formula_or_cask.is_a?(Formula)
220-
formula_or_cask.outdated?(fetch_head: args.fetch_HEAD?)
252+
if minimum_version.present?
253+
formula_outdated_kegs(formula_or_cask).present?
254+
else
255+
formula_or_cask.outdated?(fetch_head: args.fetch_HEAD?)
256+
end
221257
else
258+
if minimum_version.present?
259+
next MinimumVersion.cask_installed_below?(formula_or_cask, T.must(minimum_version))
260+
end
261+
222262
cask_greedy = upgrade_greedy_cask?(args.greedy?, formula_or_cask)
223263

224264
formula_or_cask.outdated?(greedy: cask_greedy,
@@ -228,6 +268,11 @@ def select_outdated(formulae_or_casks)
228268
end
229269
end
230270

271+
sig { params(formula: Formula).returns(T::Array[Keg]) }
272+
def formula_outdated_kegs(formula)
273+
MinimumVersion.formula_outdated_kegs(formula, minimum_version, fetch_head: args.fetch_HEAD?)
274+
end
275+
231276
sig { params(greedy: T::Boolean, cask: Cask::Cask).returns(T::Boolean) }
232277
def upgrade_greedy_cask?(greedy, cask)
233278
return true if greedy

Library/Homebrew/cmd/upgrade.rb

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require "cask/upgrade"
1010
require "api"
1111
require "reinstall"
12+
require "minimum_version"
1213

1314
module Homebrew
1415
module Cmd
@@ -55,6 +56,9 @@ class FinalUpgradeSummary < T::Struct
5556
description: "Print the verification and post-install steps."
5657
switch "-n", "--dry-run",
5758
description: "Show what would be upgraded, but do not actually upgrade anything."
59+
flag "--minimum-version=", "--min-version=",
60+
description: "Only upgrade a named formula or cask with an installed version below the given " \
61+
"minimum version."
5862
switch "--ask",
5963
description: "Ask for confirmation before downloading and upgrading. " \
6064
"Print the same plan as `--dry-run`, including available download sizes.",
@@ -151,6 +155,8 @@ def run
151155
if args.build_from_source? && args.named.empty?
152156
raise ArgumentError, "`--build-from-source` requires at least one formula"
153157
end
158+
raise UsageError, "`--minimum-version` requires exactly one formula or cask argument." if
159+
minimum_version.present? && args.named.length != 1
154160

155161
formulae = T.let([], T::Array[Formula])
156162
casks = T.let([], T::Array[Cask::Cask])
@@ -294,6 +300,35 @@ def run
294300

295301
private
296302

303+
sig { returns(T.nilable(String)) }
304+
def minimum_version = args.minimum_version || args.min_version
305+
306+
sig { params(formula: Formula).returns(T::Boolean) }
307+
def formula_outdated?(formula)
308+
version = minimum_version
309+
return formula.outdated?(fetch_head: args.fetch_HEAD?) if version.blank?
310+
311+
formula.outdated?(fetch_head: args.fetch_HEAD?) &&
312+
MinimumVersion.formula_outdated_kegs(formula, version, fetch_head: args.fetch_HEAD?).present?
313+
end
314+
315+
sig { params(casks: T::Array[Cask::Cask], quiet: T::Boolean).returns(T::Array[Cask::Cask]) }
316+
def minimum_version_casks(casks, quiet: args.quiet?)
317+
version = minimum_version
318+
return casks if version.blank?
319+
320+
casks.select do |cask|
321+
if MinimumVersion.cask_installed_below?(cask, version)
322+
true
323+
else
324+
unless quiet
325+
opoo "Not upgrading #{cask.token}, the installed version is not below the minimum version #{version}"
326+
end
327+
false
328+
end
329+
end
330+
end
331+
297332
sig {
298333
params(formulae: T::Array[Formula], show_upgrade_summary: T::Boolean,
299334
dry_run: T::Boolean).returns(T.nilable(FormulaeUpgradeContext))
@@ -310,15 +345,32 @@ def formulae_upgrade_context(formulae, show_upgrade_summary: true, dry_run: args
310345
end
311346
end
312347

348+
not_outdated = T.let([], T::Array[Formula])
313349
if formulae.blank?
314350
outdated = Formula.installed.select do |f|
351+
formula_outdated?(f)
352+
end
353+
elsif minimum_version.present?
354+
outdated, not_outdated = formulae.partition do |f|
315355
f.outdated?(fetch_head: args.fetch_HEAD?)
316356
end
357+
outdated, minimum_version_skipped = outdated.partition do |f|
358+
MinimumVersion.formula_outdated_kegs(f, minimum_version, fetch_head: args.fetch_HEAD?).present?
359+
end
360+
361+
minimum_version_skipped.each do |f|
362+
next if args.quiet?
363+
364+
opoo "Not upgrading #{f.full_specified_name}, the installed version is not below " \
365+
"the minimum version #{minimum_version}"
366+
end
317367
else
318368
outdated, not_outdated = formulae.partition do |f|
319-
f.outdated?(fetch_head: args.fetch_HEAD?)
369+
formula_outdated?(f)
320370
end
371+
end
321372

373+
if formulae.present?
322374
not_outdated.each do |f|
323375
latest_keg = f.installed_kegs.max_by(&:scheme_and_version)
324376
if latest_keg.nil?
@@ -650,6 +702,9 @@ def prefetch_outdated_casks!(casks, download_queue:, prefetch_names: nil,
650702
return false if args.formula?
651703
return false if args.ask?
652704

705+
casks = minimum_version_casks(casks, quiet: true)
706+
return false if minimum_version.present? && casks.empty?
707+
653708
outdated_casks = Cask::Upgrade.outdated_casks(
654709
casks,
655710
args:,
@@ -709,6 +764,9 @@ def upgrade_outdated_casks!(casks, skip_prefetch: false, show_upgrade_summary: t
709764
download_queue: nil)
710765
return false if args.formula?
711766

767+
casks = minimum_version_casks(casks)
768+
return false if minimum_version.present? && casks.empty?
769+
712770
Cask::Upgrade.upgrade_casks!(
713771
*casks,
714772
force: args.force?,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "cask/cask"
5+
require "cask/dsl/version"
6+
require "formula"
7+
require "pkg_version"
8+
9+
module Homebrew
10+
module MinimumVersion
11+
sig {
12+
params(formula: Formula, minimum_version: T.nilable(String), fetch_head: T::Boolean).returns(T::Array[Keg])
13+
}
14+
def self.formula_outdated_kegs(formula, minimum_version, fetch_head:)
15+
return formula.outdated_kegs(fetch_head:) if minimum_version.blank?
16+
17+
minimum_pkg_version = PkgVersion.parse(minimum_version)
18+
formula.installed_kegs.select do |keg|
19+
keg.version_scheme < formula.version_scheme ||
20+
(keg.version_scheme == formula.version_scheme && keg.version < minimum_pkg_version)
21+
end
22+
end
23+
24+
sig { params(cask: Cask::Cask, minimum_version: String).returns(T::Boolean) }
25+
def self.cask_installed_below?(cask, minimum_version)
26+
minimum_cask_version = comparable_cask_version(minimum_version)
27+
raise UsageError, "invalid `--minimum-version`: #{minimum_version}" if minimum_cask_version.nil?
28+
29+
installed_version = cask.installed_version
30+
return false if installed_version.blank?
31+
32+
installed_cask_version = comparable_cask_version(installed_version)
33+
return false if installed_cask_version.nil?
34+
35+
installed_cask_version < minimum_cask_version
36+
end
37+
38+
sig { params(version: String).returns(T.nilable(::Version)) }
39+
def self.comparable_cask_version(version)
40+
cask_version = Cask::DSL::Version.new(version)
41+
return if cask_version.latest?
42+
43+
::Version.new(cask_version.to_s)
44+
rescue TypeError
45+
nil
46+
end
47+
private_class_method :comparable_cask_version
48+
end
49+
end

Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/outdated.rbi

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/upgrade_cmd.rbi

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)