From 1674f4f44b2b45bc9b9e38afdd44407f222b8277 Mon Sep 17 00:00:00 2001 From: Michael Cho Date: Mon, 7 Sep 2026 20:13:45 -0400 Subject: [PATCH 1/4] Allow limiting total build-from-source dependents Rust and Go PRs run for over 1 day and continue to grow in total dependents. To make these more reasonable, limit the maximum number of build-from-source dependents per runner (shard). Selected based on analytics data as more popular formulae are more likely to be built from source by users so identifying these earlier would allow reporting issues or opening PRs upstream. --- .../test/test_bot/formulae_dependents_spec.rb | 70 +++++++++++++++++++ .../Homebrew/test_bot/formulae_dependents.rb | 70 ++++++++++++++++--- 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/Library/Homebrew/test/test_bot/formulae_dependents_spec.rb b/Library/Homebrew/test/test_bot/formulae_dependents_spec.rb index 8fe436f34d104..9183a27716ba7 100644 --- a/Library/Homebrew/test/test_bot/formulae_dependents_spec.rb +++ b/Library/Homebrew/test/test_bot/formulae_dependents_spec.rb @@ -54,4 +54,74 @@ expect(formulae_dependents.dependents_for_shard([[dependent, dependent.deps.to_a]], "2/2")).to be_empty end end + + describe "#split_source_dependents" do + let(:dependents) { dependents_hash.values } + let(:dependents_hash) do + 14.downto(6).to_h do |i| + f = formula "dependent-#{i}" do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/dependent-#{i}/1.0.tar.gz" + depends_on "dependency" + end + [i, [f, f.deps.to_a]] + end + end + + before do + stub_formula_loader( + formula("dependency") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/dependency/1.0.tar.gz" + end, + ) + allow(Homebrew::API::Analytics).to receive(:fetch).with("install", 90).and_return({ + "items" => 1.upto(20).map { |i| { "number" => i, "formula" => "dependent-#{i}" } }, + }) + allow(formulae_dependents).to receive_messages( + build_dependent_from_source?: true, + bottled_or_built?: true, + ) + end + + it "does not source build dependents with unbottled dependency" do + unbottled_dependency = formula "unbottled-dependency" do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/unbottled-dependency/1.0.tar.gz" + depends_on "dependency" + end + stub_formula_loader unbottled_dependency + allow(formulae_dependents).to receive(:bottled_or_built?).with(unbottled_dependency, []).and_return(false) + + source_dependents = dependents_hash.values + dependent_with_unbottled_dep = formula "dependent-5" do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/dependent-5/1.0.tar.gz" + depends_on "dependency" + depends_on "unbottled-dependency" + end + not_source_dependent = [dependent_with_unbottled_dep, dependent_with_unbottled_dep.deps.to_a] + dependents_hash[5] = not_source_dependent + + expect(formulae_dependents.split_source_dependents(dependents)).to eq [ + source_dependents, + [not_source_dependent], + ] + end + + it "limits total source build dependents to upper bound and orders on analytics" do + expect(formulae_dependents.split_source_dependents(dependents, 1)).to eq [ + [dependents_hash.fetch(6)], + 7.upto(14).map { dependents_hash.fetch(it) }, + ] + expect(formulae_dependents.split_source_dependents(dependents, 5)).to eq [ + 6.upto(10).map { dependents_hash.fetch(it) }, + 11.upto(14).map { dependents_hash.fetch(it) }, + ] + expect(formulae_dependents.split_source_dependents(dependents, 7)).to eq [ + 6.upto(12).map { dependents_hash.fetch(it) }, + 13.upto(14).map { dependents_hash.fetch(it) }, + ] + end + end end diff --git a/Library/Homebrew/test_bot/formulae_dependents.rb b/Library/Homebrew/test_bot/formulae_dependents.rb index db97f1db3733a..8208a6e9e5189 100644 --- a/Library/Homebrew/test_bot/formulae_dependents.rb +++ b/Library/Homebrew/test_bot/formulae_dependents.rb @@ -4,6 +4,9 @@ module Homebrew module TestBot class FormulaeDependents < TestFormulae + MAX_DEPENDENTS_FROM_SOURCE = 10 + private_constant :MAX_DEPENDENTS_FROM_SOURCE + DependentWithDependencies = T.type_alias { [Formula, T::Array[Dependency]] } private_constant :DependentWithDependencies @@ -30,6 +33,7 @@ def initialize(tap:, git:, dry_run:, fail_fast:, verbose:) @tested_dependents = T.let([], T::Array[String]) @formulae_dependents_filter = T.let(nil, T.nilable(T::Array[String])) @dependent_pairs_by_formula = T.let({}, T::Hash[String, T::Array[DependentWithDependencies]]) + @tested_source_dependents_count = T.let(0, Integer) end sig { params(args: Homebrew::Cmd::TestBotCmd::Args).void } @@ -49,6 +53,7 @@ def run!(args:) @testing_formulae_with_tested_dependents = [] @tested_dependents_list = Pathname("tested-dependents-#{Utils::Bottles.tag}.txt") + @tested_source_dependents_count = 0 @dependent_testing_formulae = sorted_formulae - skipped_or_failed_formulae @@ -162,8 +167,56 @@ def dependents_for_shard(dependents, shard) shards.fetch(shard_index - 1).sort_by { |dependent, _| dependent.full_name } end + sig { + params( + dependents: T::Array[DependentWithDependencies], + max: Integer, + ).returns([T::Array[DependentWithDependencies], T::Array[DependentWithDependencies]]) + } + def split_source_dependents(dependents, max = MAX_DEPENDENTS_FROM_SOURCE) + source_dependents, dependents = dependents.partition do |dependent, deps| + next false unless build_dependent_from_source?(dependent) + + deps.all? do |d| + bottled_or_built?(d.to_formula, @dependent_testing_formulae) + end + end + + return [source_dependents, dependents] if source_dependents.count <= max + + ohai "Only source building #{max} of #{source_dependents.count} dependents" + + if (ranks = formula_install_ranks).present? + last = ranks.each_value.max.to_i + 1 + source_dependents.sort_by! do |dependent, _| + [ranks.fetch(dependent.full_name, last), dependent.full_name] + end + end + dependents.concat(source_dependents.slice!(max..).to_a) + [source_dependents, dependents] + end + private + sig { returns(T::Hash[String, Integer]) } + def formula_install_ranks + @formula_install_ranks ||= T.let(begin + analytics = begin + require "api/analytics" + Homebrew::API::Analytics.fetch "install", 90 + rescue ArgumentError + {} + end + analytics["items"].to_a.each_with_object({}) do |item, hash| + formula = item["formula"] + number = item["number"] + next if formula.blank? || number.blank? + + hash[formula.to_s] = number.to_i + end + end, T.nilable(T::Hash[String, Integer])) + end + sig { params(installable_bottles: T::Array[String], args: Homebrew::Cmd::TestBotCmd::Args).void } def install_formulae_if_needed_from_bottles!(installable_bottles, args:) installable_bottles.each do |formula_name| @@ -224,6 +277,7 @@ def dependent_formulae!(formula_name, args:) source_dependents.each do |dependent| install_dependent(dependent, testable_dependents, build_from_source: true, args:) install_dependent(dependent, testable_dependents, args:) if bottled?(dependent) + @tested_source_dependents_count += 1 end bottled_dependents.each do |dependent| @@ -249,16 +303,12 @@ def dependents_for_formula(formula, formula_name, args:) dependents.reject! { |dependent, _| @tested_dependents.include?(dependent.full_name) } # Split into dependents that we could potentially be building from source and those - # we should not. The criteria is that a dependent must have bottled dependencies, and - # either the `--build-dependents-from-source` flag was passed or a dependent has no - # bottle on the current OS. - source_dependents, dependents = dependents.partition do |dependent, deps| - next false unless build_dependent_from_source?(dependent) - - all_deps_bottled_or_built = deps.all? do |d| - bottled_or_built?(d.to_formula, @dependent_testing_formulae) - end - args.build_dependents_from_source? && all_deps_bottled_or_built + # we should not. The criteria is that a dependent must have bottled dependencies and + # the `--build-dependents-from-source` flag was passed + max = MAX_DEPENDENTS_FROM_SOURCE - @tested_source_dependents_count + source_dependents = [] + if args.build_dependents_from_source? && max.positive? + source_dependents, dependents = split_source_dependents(dependents, max) end # From the non-source list, get rid of any dependents we are only a build dependency to From 11a69b20e90a339d49d7a7e919ddfdd8e726d56d Mon Sep 17 00:00:00 2001 From: Michael Cho Date: Tue, 8 Sep 2026 11:30:55 -0400 Subject: [PATCH 2/4] test_bot/formulae_dependents: limit per formula rather than per runner This simplifies the logic and allows each formula to have coverage in the case that multiple formulae need to verify source-build of dependents. --- Library/Homebrew/test_bot/formulae_dependents.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Library/Homebrew/test_bot/formulae_dependents.rb b/Library/Homebrew/test_bot/formulae_dependents.rb index 8208a6e9e5189..c995eed143c6d 100644 --- a/Library/Homebrew/test_bot/formulae_dependents.rb +++ b/Library/Homebrew/test_bot/formulae_dependents.rb @@ -33,7 +33,6 @@ def initialize(tap:, git:, dry_run:, fail_fast:, verbose:) @tested_dependents = T.let([], T::Array[String]) @formulae_dependents_filter = T.let(nil, T.nilable(T::Array[String])) @dependent_pairs_by_formula = T.let({}, T::Hash[String, T::Array[DependentWithDependencies]]) - @tested_source_dependents_count = T.let(0, Integer) end sig { params(args: Homebrew::Cmd::TestBotCmd::Args).void } @@ -53,7 +52,6 @@ def run!(args:) @testing_formulae_with_tested_dependents = [] @tested_dependents_list = Pathname("tested-dependents-#{Utils::Bottles.tag}.txt") - @tested_source_dependents_count = 0 @dependent_testing_formulae = sorted_formulae - skipped_or_failed_formulae @@ -277,7 +275,6 @@ def dependent_formulae!(formula_name, args:) source_dependents.each do |dependent| install_dependent(dependent, testable_dependents, build_from_source: true, args:) install_dependent(dependent, testable_dependents, args:) if bottled?(dependent) - @tested_source_dependents_count += 1 end bottled_dependents.each do |dependent| @@ -304,11 +301,11 @@ def dependents_for_formula(formula, formula_name, args:) # Split into dependents that we could potentially be building from source and those # we should not. The criteria is that a dependent must have bottled dependencies and - # the `--build-dependents-from-source` flag was passed - max = MAX_DEPENDENTS_FROM_SOURCE - @tested_source_dependents_count + # the `--build-dependents-from-source` flag was passed. Total source build dependents + # are limited per formula to avoid overly long CI runtime. source_dependents = [] - if args.build_dependents_from_source? && max.positive? - source_dependents, dependents = split_source_dependents(dependents, max) + if args.build_dependents_from_source? + source_dependents, dependents = split_source_dependents(dependents) end # From the non-source list, get rid of any dependents we are only a build dependency to From dc01fc873a661075bfb8dbc27e2adceac5b86651 Mon Sep 17 00:00:00 2001 From: Michael Cho Date: Tue, 8 Sep 2026 12:54:03 -0400 Subject: [PATCH 3/4] test-bot: clarify documentation on source building dependents "rather than testing bottles" was not true and should be in addition to. Also add details on limit explaining the "per shard" behaviour is intentional and noting the analytics selection is done within a sharded group (thus it may not be the top 40 dependents but rather the top 10 dependents + 30 more that are relatively popular w.r.t. sharding algorithm). --- Library/Homebrew/dev-cmd/test-bot.rb | 3 ++- Library/Homebrew/test_bot/formulae_dependents.rb | 2 +- completions/fish/brew.fish | 2 +- completions/zsh/_brew | 2 +- docs/Manpage.md | 4 +++- manpages/brew.1 | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Library/Homebrew/dev-cmd/test-bot.rb b/Library/Homebrew/dev-cmd/test-bot.rb index 45eae7e19ed49..3697d840ff7f5 100755 --- a/Library/Homebrew/dev-cmd/test-bot.rb +++ b/Library/Homebrew/dev-cmd/test-bot.rb @@ -25,7 +25,8 @@ class TestBotCmd < AbstractCommand switch "--build-from-source", description: "Build from source rather than building bottles." switch "--build-dependents-from-source", - description: "Build dependents from source rather than testing bottles." + description: "Build a limited set of dependents from source in addition to testing bottles. " \ + "Up to 10 per formula per shard, prioritising popular dependents in a sharded group." switch "--junit", description: "generate a JUnit XML test results file." switch "--keep-old", diff --git a/Library/Homebrew/test_bot/formulae_dependents.rb b/Library/Homebrew/test_bot/formulae_dependents.rb index c995eed143c6d..85b79818c9da9 100644 --- a/Library/Homebrew/test_bot/formulae_dependents.rb +++ b/Library/Homebrew/test_bot/formulae_dependents.rb @@ -302,7 +302,7 @@ def dependents_for_formula(formula, formula_name, args:) # Split into dependents that we could potentially be building from source and those # we should not. The criteria is that a dependent must have bottled dependencies and # the `--build-dependents-from-source` flag was passed. Total source build dependents - # are limited per formula to avoid overly long CI runtime. + # are limited per formula per shard to avoid overly long CI runtime. source_dependents = [] if args.build_dependents_from_source? source_dependents, dependents = split_source_dependents(dependents) diff --git a/completions/fish/brew.fish b/completions/fish/brew.fish index 93f258d1c1886..5f0be55122281 100644 --- a/completions/fish/brew.fish +++ b/completions/fish/brew.fish @@ -1943,7 +1943,7 @@ __fish_brew_complete_arg 'test' -a '(__fish_brew_suggest_formulae_installed)' __fish_brew_complete_cmd 'test-bot' 'Tests the full lifecycle of a Homebrew change to a tap (Git repository)' __fish_brew_complete_arg 'test-bot' -l added-formulae -d 'Use these added formulae rather than running the formulae detection steps' -__fish_brew_complete_arg 'test-bot' -l build-dependents-from-source -d 'Build dependents from source rather than testing bottles' +__fish_brew_complete_arg 'test-bot' -l build-dependents-from-source -d 'Build a limited set of dependents from source in addition to testing bottles. Up to 10 per formula per shard, prioritising popular dependents in a sharded group' __fish_brew_complete_arg 'test-bot' -l build-from-source -d 'Build from source rather than building bottles' __fish_brew_complete_arg 'test-bot' -l cleanup -d 'Clean all state from the Homebrew directory. Use with care!' __fish_brew_complete_arg 'test-bot' -l debug -d 'Display any debugging information' diff --git a/completions/zsh/_brew b/completions/zsh/_brew index e813682adaba4..bb07bb794613a 100644 --- a/completions/zsh/_brew +++ b/completions/zsh/_brew @@ -2511,7 +2511,7 @@ _brew_test() { _brew_test_bot() { _arguments \ '(--only-formulae-detect)--added-formulae[Use these added formulae rather than running the formulae detection steps]' \ - '--build-dependents-from-source[Build dependents from source rather than testing bottles]' \ + '--build-dependents-from-source[Build a limited set of dependents from source in addition to testing bottles. Up to 10 per formula per shard, prioritising popular dependents in a sharded group]' \ '--build-from-source[Build from source rather than building bottles]' \ '--cleanup[Clean all state from the Homebrew directory. Use with care!]' \ '--debug[Display any debugging information]' \ diff --git a/docs/Manpage.md b/docs/Manpage.md index 18ec57f925a1a..47ef469572772 100644 --- a/docs/Manpage.md +++ b/docs/Manpage.md @@ -3750,7 +3750,9 @@ and Linux workers. `--build-dependents-from-source` -: Build dependents from source rather than testing bottles. +: Build a limited set of dependents from source in addition to testing bottles. + Up to 10 per formula per shard, prioritising popular dependents in a sharded + group. `--junit` diff --git a/manpages/brew.1 b/manpages/brew.1 index 7fad4b9a78cf7..20f68c3101963 100644 --- a/manpages/brew.1 +++ b/manpages/brew.1 @@ -2360,7 +2360,7 @@ Don\[u2019]t check if the local system is set up correctly\. Build from source rather than building bottles\. .TP \fB\-\-build\-dependents\-from\-source\fP -Build dependents from source rather than testing bottles\. +Build a limited set of dependents from source in addition to testing bottles\. Up to 10 per formula per shard, prioritising popular dependents in a sharded group\. .TP \fB\-\-junit\fP generate a JUnit XML test results file\. From bcfe586573d995da7414b4d105209bfc9d8212af Mon Sep 17 00:00:00 2001 From: Michael Cho Date: Tue, 8 Sep 2026 13:53:03 -0400 Subject: [PATCH 4/4] test_bot/formulae_dependents: Copilot suggestion on inlining --- .../Homebrew/test_bot/formulae_dependents.rb | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/Library/Homebrew/test_bot/formulae_dependents.rb b/Library/Homebrew/test_bot/formulae_dependents.rb index 85b79818c9da9..67b453c152c8a 100644 --- a/Library/Homebrew/test_bot/formulae_dependents.rb +++ b/Library/Homebrew/test_bot/formulae_dependents.rb @@ -184,20 +184,6 @@ def split_source_dependents(dependents, max = MAX_DEPENDENTS_FROM_SOURCE) ohai "Only source building #{max} of #{source_dependents.count} dependents" - if (ranks = formula_install_ranks).present? - last = ranks.each_value.max.to_i + 1 - source_dependents.sort_by! do |dependent, _| - [ranks.fetch(dependent.full_name, last), dependent.full_name] - end - end - dependents.concat(source_dependents.slice!(max..).to_a) - [source_dependents, dependents] - end - - private - - sig { returns(T::Hash[String, Integer]) } - def formula_install_ranks @formula_install_ranks ||= T.let(begin analytics = begin require "api/analytics" @@ -213,8 +199,19 @@ def formula_install_ranks hash[formula.to_s] = number.to_i end end, T.nilable(T::Hash[String, Integer])) + + if @formula_install_ranks.present? + last = @formula_install_ranks.each_value.max.to_i + 1 + source_dependents.sort_by! do |dependent, _| + [@formula_install_ranks.fetch(dependent.full_name, last), dependent.full_name] + end + end + dependents.concat(source_dependents.slice!(max..).to_a) + [source_dependents, dependents] end + private + sig { params(installable_bottles: T::Array[String], args: Homebrew::Cmd::TestBotCmd::Args).void } def install_formulae_if_needed_from_bottles!(installable_bottles, args:) installable_bottles.each do |formula_name|