From bfd484714edd2726a02a51646208b68c979bbd41 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Sat, 17 Oct 2026 11:39:08 +0200 Subject: [PATCH] Make source build dependency upgrades more liberal - Since #15927, installing from a bottle doesn't upgrade outdated dependencies the bottle is known to work with. Building from source still upgrades every outdated build dependency, so e.g. `ccache` on Intel macOS rebuilds `rust` and `llvm` just to build `ruby` for `asciidoctor`. - When building from source, keep an outdated build dependency whose installed `compatibility_version` matches the current formula, along with the dependencies it was built against. `brew upgrade` still upgrades it. - Upgrade it anyway when building a bottle or if this install would incompatibly upgrade one of the dependencies it was built against. - Load `compatibility_version` from the API: `FormulaStruct` dropped it, so formulae loaded from the API always returned `nil`. - Let `Dependency#installed?` check `minimum_compatibility_version` without also needing a `minimum_version`. --- Library/Homebrew/api/formula_struct.rb | 1 + Library/Homebrew/dependency.rb | 8 +- Library/Homebrew/formula_installer.rb | 44 ++++++++++- Library/Homebrew/formulary.rb | 1 + Library/Homebrew/test/dependency_spec.rb | 25 ++++++ .../Homebrew/test/formula_installer_spec.rb | 77 +++++++++++++++++++ Library/Homebrew/test/formulary_spec.rb | 7 ++ 7 files changed, 158 insertions(+), 5 deletions(-) diff --git a/Library/Homebrew/api/formula_struct.rb b/Library/Homebrew/api/formula_struct.rb index 44b86ec046a8f..2d9fcb04ae4cc 100644 --- a/Library/Homebrew/api/formula_struct.rb +++ b/Library/Homebrew/api/formula_struct.rb @@ -90,6 +90,7 @@ def self.from_hash(formula_hash) const :bottle_checksums, T::Array[T::Hash[Symbol, T.any(String, Symbol)]], default: [] const :bottle_rebuild, Integer, default: 0 const :caveats, T.nilable(String) + const :compatibility_version, T.nilable(Integer) const :conflicts, T::Array[[String, T::Hash[Symbol, String]]], default: [] const :deprecate_args, T::Hash[Symbol, T.nilable(T.any(String, Symbol))], default: {} const :desc, String diff --git a/Library/Homebrew/dependency.rb b/Library/Homebrew/dependency.rb index 4f16c34809cfb..9a32d560c7152 100644 --- a/Library/Homebrew/dependency.rb +++ b/Library/Homebrew/dependency.rb @@ -81,7 +81,7 @@ def installed?(minimum_version: nil, minimum_revision: nil, minimum_compatibilit return true if formula.latest_version_installed? - return false if minimum_version.blank? + return false if minimum_version.blank? && minimum_compatibility_version.blank? installed_keg = formula.any_installed_keg return false unless installed_keg @@ -89,8 +89,6 @@ def installed?(minimum_version: nil, minimum_revision: nil, minimum_compatibilit # If the keg name doesn't match, we may have moved from an alias to a full formula and need to upgrade. return false unless formula.possible_names.include?(installed_keg.name) - installed_version = installed_keg.version - # If both the formula and minimum dependency have a compatibility_version set, # and they match, the dependency is satisfied regardless of version/revision. if minimum_compatibility_version.present? && formula.compatibility_version.present? @@ -102,6 +100,10 @@ def installed?(minimum_version: nil, minimum_revision: nil, minimum_compatibilit formula.compatibility_version == minimum_compatibility_version end + return false if minimum_version.blank? + + installed_version = installed_keg.version + # Tabs prior to 4.1.18 did not have revision or pkg_version fields. # As a result, we have to be more conversative when we do not have # a minimum revision from the tab and assume that if the formula has a diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index b0c350baf3605..21f162a913f15 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -783,8 +783,14 @@ def expand_requirements unsatisfied_reqs end - sig { params(formula: Formula).returns(T::Array[Dependency]) } - def expand_dependencies_for_formula(formula) + sig { + params( + formula: Formula, + keepable_build_dependencies: T.nilable(T::Array[String]), + kept_build_dependencies: T::Hash[Formula, T::Array[String]], + ).returns(T::Array[Dependency]) + } + def expand_dependencies_for_formula_once(formula, keepable_build_dependencies, kept_build_dependencies) # Cache for this expansion only. FormulaInstaller has a lot of inputs which can alter expansion. cache_key = "FormulaInstaller-#{formula.full_name}-#{Time.now.to_f}" formula_cache = T.let({}, T::Hash[Dependency, Formula]) @@ -819,7 +825,41 @@ def expand_dependencies_for_formula(formula) end next Dependable::SKIP if satisfied + next if !dep.build? || build_bottle? + + dep_formula = dep.to_formula + next if keepable_build_dependencies&.exclude?(dep_formula.full_name) + next unless dep.satisfied?(minimum_compatibility_version: dep_formula.compatibility_version) + + runtime_dependencies = dep_formula.any_installed_keg&.runtime_dependencies + next if runtime_dependencies.nil? + + # Keep the installed build dependency as-is, including the dependencies it was built against. + kept_build_dependencies[dep_formula] = runtime_dependencies.map { |runtime_dep| runtime_dep["full_name"] } + Dependable::PRUNE + end + end + + sig { params(formula: Formula).returns(T::Array[Dependency]) } + def expand_dependencies_for_formula(formula) + kept_build_dependencies = T.let({}, T::Hash[Formula, T::Array[String]]) + deps = expand_dependencies_for_formula_once(formula, nil, kept_build_dependencies) + + # Each pass keeps fewer build dependencies than the last, so at worst this ends with none kept. + kept_build_dependencies.size.times do + incompatible_upgrades = deps.filter_map do |dep| + dep_formula = dep.to_formula + dep_formula.full_name unless dep.installed?(minimum_compatibility_version: dep_formula.compatibility_version) + end << formula.full_name + keepable_build_dependencies = kept_build_dependencies.filter_map do |kept, runtime_dependency_names| + kept.full_name unless runtime_dependency_names.intersect?(incompatible_upgrades) + end + return deps if keepable_build_dependencies.size == kept_build_dependencies.size + + kept_build_dependencies = {} + deps = expand_dependencies_for_formula_once(formula, keepable_build_dependencies, kept_build_dependencies) end + deps end sig { returns(T::Array[Dependency]) } diff --git a/Library/Homebrew/formulary.rb b/Library/Homebrew/formulary.rb index 0712e3d519a6e..9fee2b32df947 100644 --- a/Library/Homebrew/formulary.rb +++ b/Library/Homebrew/formulary.rb @@ -252,6 +252,7 @@ def self.load_formula_from_struct!(name, formula_struct, api_source:, tap_git_he license formula_struct.license revision formula_struct.revision version_scheme formula_struct.version_scheme + compatibility_version formula_struct.compatibility_version if formula_struct.stable? stable do diff --git a/Library/Homebrew/test/dependency_spec.rb b/Library/Homebrew/test/dependency_spec.rb index 6040c16572055..f6888786f6d9d 100644 --- a/Library/Homebrew/test/dependency_spec.rb +++ b/Library/Homebrew/test/dependency_spec.rb @@ -132,6 +132,31 @@ end end + describe "Dependency#installed? with minimum_compatibility_version" do + subject(:dependency) { described_class.new("foo") } + + let(:keg) { instance_double(Keg, name: "foo") } + + before do + allow(dependency).to receive(:to_installed_formula).and_return( + instance_double(Formula, opt_prefix: instance_double(Pathname, exist?: true), + latest_version_installed?: false, any_installed_keg: keg, + possible_names: ["foo"], compatibility_version: 1), + ) + allow(Tab).to receive(:for_keg).with(keg).and_return( + instance_double(Tab, source: { "versions" => { "compatibility_version" => 1 } }), + ) + end + + it "is true for an outdated keg with a matching compatibility_version" do + expect(dependency.installed?(minimum_compatibility_version: 1)).to be(true) + end + + it "is false for an outdated keg with a different compatibility_version" do + expect(dependency.installed?(minimum_compatibility_version: 2)).to be(false) + end + end + describe "Dependency#satisfied? with bottle_os_version" do subject(:dependency) { described_class.new("foo") } diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index b60f27bec14ea..cc31db2928569 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -1157,6 +1157,83 @@ class Requested < Formula expect(deps.map(&:name)).to eq(%w[shared first-parent second-parent]) end + + context "with an outdated build dependency with a matching compatibility_version" do + let(:build_dep) { Dependency.new("homebrew-compatible-build-dependency", [:build]) } + let(:runtime_dep_formula) do + formula "homebrew-build-dependency-runtime-dependency" do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + end + let(:f) do + formula "homebrew-keep-compatible-build-dependency" do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + end + let(:build_bottle) { false } + let(:installer) { described_class.new(f, build_bottle:) } + + before do + build_dep_formula = formula build_dep.name do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + keg = instance_double(Keg, runtime_dependencies: [{ "full_name" => runtime_dep_formula.full_name }]) + allow(build_dep_formula).to receive_messages( + compatibility_version: 1, + deps: [Dependency.new(runtime_dep_formula.name)], + any_installed_keg: keg, + ) + stub_formula_loader(build_dep_formula) + stub_formula_loader(runtime_dep_formula) + allow(build_dep).to receive(:satisfied?).and_return(false) + allow(build_dep).to receive(:satisfied?).with(minimum_compatibility_version: 1).and_return(true) + allow(installer).to receive_messages(effective_build_options_for: BuildOptions.new(Options.new, Options.new), + install_bottle_for?: false) + end + + it "keeps it and the dependencies it was built against" do + allow(f).to receive(:deps).and_return([build_dep]) + + expect(installer.expand_dependencies_for_formula(f)).to be_empty + end + + it "upgrades it if a dependency it was built against is upgraded" do + allow(f).to receive(:deps).and_return([build_dep, Dependency.new(runtime_dep_formula.name)]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + it "keeps other compatible build dependencies if one is upgraded" do + other_build_dep = Dependency.new("homebrew-other-compatible-build-dependency", [:build]) + other_build_dep_formula = formula other_build_dep.name do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + allow(other_build_dep_formula).to receive_messages( + compatibility_version: 1, + any_installed_keg: instance_double(Keg, runtime_dependencies: []), + ) + stub_formula_loader(other_build_dep_formula) + allow(other_build_dep).to receive(:satisfied?).and_return(false) + allow(other_build_dep).to receive(:satisfied?).with(minimum_compatibility_version: 1).and_return(true) + allow(f).to receive(:deps).and_return([build_dep, other_build_dep, Dependency.new(runtime_dep_formula.name)]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).not_to include(other_build_dep.name) + end + + context "when building a bottle" do + let(:build_bottle) { true } + + it "upgrades it" do + allow(f).to receive(:deps).and_return([build_dep]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + end + end end describe "versioned keg-only linking defaults" do diff --git a/Library/Homebrew/test/formulary_spec.rb b/Library/Homebrew/test/formulary_spec.rb index 471c8ae055739..954007757494d 100644 --- a/Library/Homebrew/test/formulary_spec.rb +++ b/Library/Homebrew/test/formulary_spec.rb @@ -864,6 +864,13 @@ def formula_json_contents(extra_items = {}) expect(formula.loaded_from_internal_api?).to be true end + it "returns a Formula with a compatibility_version" do + allow(Homebrew::API::Formula).to receive(:all_formulae) + .and_return formula_json_contents("compatibility_version" => 2) + + expect(described_class.factory(formula_name).compatibility_version).to eq 2 + end + it "returns a Formula with no_linkage dependencies" do allow(Homebrew::API::Formula).to receive(:all_formulae) .and_return formula_json_contents("no_linkage_dependencies" => ["dep"])