From e4bb846a14377bf946369c621477ea468b76c2a7 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Sat, 17 Oct 2026 17:16:40 +0200 Subject: [PATCH 1/3] Keep outdated `:no_linkage` build dependencies - A build dependency that is only run, like `llvm` used as a compiler, isn't built against, so its `compatibility_version` doesn't matter. Requiring a match makes e.g. `ccache` on Xcode 14 rebuild `llvm` 23 even though the installed `llvm` 22 can compile it. - Let formulae tag such build dependencies `:no_linkage`, which already means the formula doesn't link against them, and keep them when outdated. The dependencies they were built against are still checked. --- Library/Homebrew/formula.rb | 8 +++++ Library/Homebrew/formula_installer.rb | 7 +++- .../Homebrew/test/formula_installer_spec.rb | 32 +++++++++++++++++++ docs/Formula-Cookbook.md | 1 + 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 39207e52b6578..9c8289835f631 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -4701,6 +4701,14 @@ def resource(name, klass = Resource, &block) # depends_on "python@3.14" => :no_linkage # ``` # + # On a `:build` dependency, such as a compiler, `:no_linkage` also lets an + # outdated installed version be used when building from source, whatever + # its `compatibility_version`. + # + # ```ruby + # depends_on "llvm" => [:build, :no_linkage] + # ``` + # # `:recommended` dependencies are built by default. # But a `--without-...` option is generated to opt-out. # diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index ae81b445b57f0..6689945942a2c 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -829,7 +829,12 @@ def expand_dependencies_for_formula_once(formula, keepable_build_dependencies, k 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) + + if dep.no_linkage? + next if !dep_formula.opt_prefix.exist? || dep.missing_options.present? + else + next unless dep.satisfied?(minimum_compatibility_version: dep_formula.compatibility_version) + end runtime_dependencies = dep_formula.any_installed_keg&.runtime_dependencies next if runtime_dependencies.nil? diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index 409973e7ce0d3..3c38d445f252b 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -1251,6 +1251,38 @@ class Requested < Formula end end end + + context "with an outdated :no_linkage build dependency with a different compatibility_version" do + let(:build_dep) { Dependency.new("homebrew-no-linkage-build-dependency", [:build, :no_linkage]) } + let(:f) do + formula "homebrew-keep-no-linkage-build-dependency" do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + end + let(:installer) { described_class.new(f) } + + before do + build_dep_formula = formula build_dep.name do + T.bind(self, T.class_of(Formula)) + url "foo-1.0" + end + allow(build_dep_formula).to receive_messages( + compatibility_version: 2, + opt_prefix: instance_double(Pathname, exist?: true), + any_installed_keg: instance_double(Keg, runtime_dependencies: []), + ) + stub_formula_loader(build_dep_formula) + allow(build_dep).to receive_messages(satisfied?: false, missing_options: Options.new) + allow(f).to receive(:deps).and_return([build_dep]) + allow(installer).to receive_messages(effective_build_options_for: BuildOptions.new(Options.new, Options.new), + install_bottle_for?: false) + end + + it "keeps it" do + expect(installer.expand_dependencies_for_formula(f)).to be_empty + end + end end describe "versioned keg-only linking defaults" do diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 50c346652becb..81ac8ba465c35 100644 --- a/docs/Formula-Cookbook.md +++ b/docs/Formula-Cookbook.md @@ -183,6 +183,7 @@ A `Hash` (e.g. `=>`) adds information to a dependency. Given a string or symbol, * `:test` means this is only required when running `brew test`. * `:no_linkage` means the formula does not link against this dependency; it does not change when the dependency is required. All explicit declarations of the same dependency within a spec and platform must agree on `:no_linkage`; stable, HEAD and different platforms may differ. + On a `:build` dependency, such as a compiler, it also lets an outdated installed version be used when building from source, whatever its `compatibility_version`. * `:optional` (not allowed in `Homebrew/homebrew-core`) generates an implicit `with-foo` option for the formula. This means that, given `depends_on "foo" => :optional`, the user must pass `--with-foo` to use the dependency. * `:recommended` (not allowed in `Homebrew/homebrew-core`) generates an implicit `without-foo` option, meaning that the dependency is enabled by default and the user must pass `--without-foo` to disable this dependency. The default description can be overridden using the [`option`](/rubydoc/Formula.html#option-class_method) syntax (in this case, the [`option` declaration](#adding-optional-steps) must precede the dependency): From cfb4f0699d3fe8fe6c42df89830a32a5213487f1 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Sat, 17 Oct 2026 20:26:38 +0200 Subject: [PATCH 2/3] Test old behaviour when building bottles - Check that `--build-bottle`, with or without `--build-from-source`, still upgrades outdated build dependencies under both keep rules: a matching `compatibility_version` (#24273) and `:no_linkage`. - Also check that a build dependency's own dependencies are upgraded when building a bottle, and that `:no_linkage` alone doesn't keep an outdated runtime dependency. --- .../Homebrew/test/formula_installer_spec.rb | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index 3c38d445f252b..798142a172598 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -1107,6 +1107,22 @@ class Requested < Formula end describe "#expand_dependencies_for_formula" do + shared_examples "the old behaviour when building a bottle" do + before { allow(f).to receive(:deps).and_return([build_dep]) } + + it "upgrades it" do + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + context "with --build-from-source" do + let(:build_from_source_formulae) { [f.full_name] } + + it "upgrades it" do + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + end + end + it "checks equal dependency satisfaction once per expansion" do shared_formula = instance_double(Formula, deps: [], name: "shared", full_name: "shared") shared_dep = Dependency.new("shared") @@ -1190,7 +1206,8 @@ class Requested < Formula end end let(:build_bottle) { false } - let(:installer) { described_class.new(f, build_bottle:) } + let(:build_from_source_formulae) { [] } + let(:installer) { described_class.new(f, build_bottle:, build_from_source_formulae:) } before do build_dep_formula = formula build_dep.name do @@ -1244,23 +1261,26 @@ class Requested < Formula context "when building a bottle" do let(:build_bottle) { true } - it "upgrades it" do - allow(f).to receive(:deps).and_return([build_dep]) + include_examples "the old behaviour when building a bottle" - expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + it "upgrades the dependencies it was built against" do + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(runtime_dep_formula.name) end end end context "with an outdated :no_linkage build dependency with a different compatibility_version" do - let(:build_dep) { Dependency.new("homebrew-no-linkage-build-dependency", [:build, :no_linkage]) } + let(:tags) { [:build, :no_linkage] } + let(:build_dep) { Dependency.new("homebrew-no-linkage-build-dependency", tags) } let(:f) do formula "homebrew-keep-no-linkage-build-dependency" do T.bind(self, T.class_of(Formula)) url "foo-1.0" end end - let(:installer) { described_class.new(f) } + let(:build_bottle) { false } + let(:build_from_source_formulae) { [] } + let(:installer) { described_class.new(f, build_bottle:, build_from_source_formulae:) } before do build_dep_formula = formula build_dep.name do @@ -1282,6 +1302,20 @@ class Requested < Formula it "keeps it" do expect(installer.expand_dependencies_for_formula(f)).to be_empty end + + context "when building a bottle" do + let(:build_bottle) { true } + + include_examples "the old behaviour when building a bottle" + end + + context "without :build" do + let(:tags) { [:no_linkage] } + + it "upgrades it" do + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + end end end From ecc49c6fb749fcdf8b865d14ecf7228d266c86fd Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Sat, 17 Oct 2026 20:40:26 +0200 Subject: [PATCH 3/3] Test more cases for keeping build dependencies - Check that the old behaviour stays for an outdated build dependency with a different `compatibility_version`, an outdated runtime dependency with a matching one and a keg whose install receipt lists no runtime dependencies. - Check that a `:no_linkage` build dependency is still upgraded if it isn't opt-linked or is missing required options. --- .../Homebrew/test/formula_installer_spec.rb | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Library/Homebrew/test/formula_installer_spec.rb b/Library/Homebrew/test/formula_installer_spec.rb index 798142a172598..33fdc73f62d99 100644 --- a/Library/Homebrew/test/formula_installer_spec.rb +++ b/Library/Homebrew/test/formula_installer_spec.rb @@ -1258,6 +1258,30 @@ class Requested < Formula expect(installer.expand_dependencies_for_formula(f).map(&:name)).not_to include(other_build_dep.name) end + it "upgrades it with a different compatibility_version" do + allow(build_dep).to receive(:satisfied?).with(minimum_compatibility_version: 1).and_return(false) + allow(f).to receive(:deps).and_return([build_dep]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + it "upgrades it if its install receipt has no runtime dependencies" do + keg = instance_double(Keg, runtime_dependencies: nil) + allow(build_dep.to_formula).to receive(:any_installed_keg).and_return(keg) + allow(f).to receive(:deps).and_return([build_dep]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + it "upgrades an outdated runtime dependency with a matching compatibility_version" do + runtime_dep = Dependency.new(build_dep.name) + allow(runtime_dep).to receive(:satisfied?).and_return(false) + allow(runtime_dep).to receive(:satisfied?).with(minimum_compatibility_version: 1).and_return(true) + allow(f).to receive(:deps).and_return([runtime_dep]) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(runtime_dep.name) + end + context "when building a bottle" do let(:build_bottle) { true } @@ -1303,6 +1327,18 @@ class Requested < Formula expect(installer.expand_dependencies_for_formula(f)).to be_empty end + it "upgrades it if it isn't opt-linked" do + allow(build_dep.to_formula).to receive(:opt_prefix).and_return(instance_double(Pathname, exist?: false)) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + + it "upgrades it if it's missing required options" do + allow(build_dep).to receive(:missing_options).and_return(Options.create(["--with-foo"])) + + expect(installer.expand_dependencies_for_formula(f).map(&:name)).to include(build_dep.name) + end + context "when building a bottle" do let(:build_bottle) { true }