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
21 changes: 21 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,24 @@ jobs:

- name: Validate advisories against OSV schema
run: check-jsonschema --schemafile osv-schema.json advisories/*.json

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Homebrew
uses: Homebrew/actions/setup-homebrew@df4b09108a1de9d6f995fe68f302b3f68bd6d2ef # 2026.07.20.1

- name: Set up Ruby
uses: Homebrew/actions/setup-ruby@df4b09108a1de9d6f995fe68f302b3f68bd6d2ef # 2026.07.20.1

- name: Run tests
run: rake test

# RuboCop is not run: the org-synced .rubocop.yml sets
# `AllCops: Include: ["**/*.rbi"]` which replaces (not extends) the
# default .rb globs, so it scans nothing in this repository. Fix belongs
# in the Homebrew/.github sync source.
26 changes: 16 additions & 10 deletions lib/repology_index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,19 @@ def build
next if distros.empty?

names = brew_entries.keys
if self.class.ambiguous_homebrew_set?(names)
ambiguous[project] = names.sort
next
end
ambiguous[project] = names.sort if self.class.ambiguous_homebrew_set?(names)
brew_entries.each do |formula, preferred|
contributions[formula][project] ||= Contribution.new(project:, preferred:, distros:)
end
end

formulae, colliding = resolve(contributions)

log_skipped("ambiguous project", ambiguous.keys) unless ambiguous.empty?
unless ambiguous.empty?
log "#{ambiguous.size} project#{"s" unless ambiguous.one?} grouping sibling formulae " \
"(indexed with shared srcnames; tighten via repology-rules PR): " \
"#{ambiguous.keys.sort.join(", ")}"
end
log_skipped("cross-project formula", colliding.keys) unless colliding.empty?

{
Expand Down Expand Up @@ -236,11 +237,16 @@ def self.homebrew_entries(entries)
result
end

# A Repology project can group unrelated Homebrew formulae (e.g. `antlr` and
# `antlr4-cpp-runtime`) whose distro packages must not be cross-attributed.
# Versioned variants of one formula (`ant`, `ant@1.9`) are the same upstream
# and are safe to fan out. Ambiguous projects are recorded in
# `meta.ambiguous_projects`; the fix is a repology-rules PR upstream.
# A Repology project can group Homebrew formulae with distinct base names
# (`wget` + `wget2`, `boost` + `boost-mpi`, `ffmpeg` + a third-party
# `ffmpeg-full`). Such projects are still indexed: each formula gets the full
# distro srcname set, and the sibling's srcnames become extra low-confidence
# distro queries in `Homebrew::Vulns::Match` whose upstream-CVE range check
# will not match this formula's identity, so the cost is uncomparable
# candidates rather than wrong `:affected`/`:fixed` claims. The set is
# recorded in `meta.ambiguous_projects` so it can be tightened via a
# repology-rules PR. Versioned variants of one formula (`ant`, `ant@1.9`)
# are not counted as ambiguous.
def self.ambiguous_homebrew_set?(names)
names.map { |n| n.sub(/@.+\z/, "") }.uniq.size > 1
end
Expand Down
20 changes: 12 additions & 8 deletions test/repology_index_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@ def test_build_paginates_by_max_key_and_aggregates_all_homebrew_names
"/projects/#{URI.encode_uri_component(max_key)}/?inrepo=homebrew"],
calls,
)
assert_equal ["ack", "ant", "ant@1.9", "curl", "libgee", "zstd"], result["formulae"].keys
assert_equal ["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee", "zstd"],
result["formulae"].keys
assert_equal result["formulae"]["ant"], result["formulae"]["ant@1.9"]
assert_equal(
{ "AlmaLinux" => ["zstd"], "Mageia" => ["zstd"], "Rocky Linux" => ["zstd"] },
Expand All @@ -205,7 +206,8 @@ def test_build_stops_on_first_short_page
fetcher, calls = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json"))
result = new_index(fetcher:).build
assert_equal 1, calls.size
assert_equal ["ack", "ant", "ant@1.9", "curl", "libgee"], result["formulae"].keys
assert_equal ["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee"],
result["formulae"].keys
end

def test_build_respects_page_limit
Expand All @@ -221,15 +223,16 @@ def test_build_omits_formulae_with_no_mapped_distros
refute_includes new_index(fetcher:).build["formulae"], "brew-only-tool"
end

def test_build_records_ambiguous_projects_and_omits_their_formulae
def test_build_records_ambiguous_projects_and_indexes_each_sibling_with_shared_srcnames
fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json"))
result = new_index(fetcher:).build
refute_includes result["formulae"], "antlr"
refute_includes result["formulae"], "antlr4-cpp-runtime"
expected = { "Debian" => ["antlr3", "antlr4", "antlr4-cpp-runtime"] }
assert_equal expected, result["formulae"]["antlr"]
assert_equal expected, result["formulae"]["antlr4-cpp-runtime"]
assert_equal({ "antlr" => ["antlr", "antlr4-cpp-runtime"] }, result["meta"]["ambiguous_projects"])
end

def test_build_logs_ambiguous_and_colliding_skips
def test_build_logs_ambiguous_and_colliding
logger = StringIO.new
page = fixture("repology_page1.json").merge(
"p1" => [{ "repo" => "homebrew", "srcname" => "foo", "status" => "newest" },
Expand All @@ -239,7 +242,7 @@ def test_build_logs_ambiguous_and_colliding_skips
)
fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => page)
RepologyIndex.new(fetcher:, sleeper: ->(_) {}, logger:).build
assert_match(/skipped 1 ambiguous project .*repology-rules.*: antlr\b/, logger.string)
assert_match(/1 project grouping sibling formulae .*repology-rules.*: antlr\b/, logger.string)
assert_match(/skipped 1 cross-project formula .*: foo\b/, logger.string)
end

Expand All @@ -257,7 +260,8 @@ def test_write_creates_output_directory_and_emits_pretty_json
fetcher, = recording_fetcher("/projects/?inrepo=homebrew" => fixture("repology_page1.json"))
new_index(fetcher:).write(out)
parsed = JSON.parse(File.read(out))
assert_equal ["ack", "ant", "ant@1.9", "curl", "libgee"], parsed["formulae"].keys
assert_equal ["ack", "ant", "ant@1.9", "antlr", "antlr4-cpp-runtime", "curl", "libgee"],
parsed["formulae"].keys
assert File.read(out).end_with?("\n")
end
end
Expand Down
Loading