diff --git a/Library/Homebrew/cask/audit.rb b/Library/Homebrew/cask/audit.rb index 77db57831d..8c53712dd0 100644 --- a/Library/Homebrew/cask/audit.rb +++ b/Library/Homebrew/cask/audit.rb @@ -1086,7 +1086,7 @@ module Cask return unless new_cask? return if (url = cask.url).nil? - user, repo = get_repo_data(%r{https?://github\.com/([^/]+)/([^/]+)/?.*}) + user, repo = get_repo_data(SharedAudits::NOTABILITY_REPOSITORY_REGEXES.fetch(:github)) return if user.nil? || repo.nil? odebug "Auditing GitHub repo" @@ -1101,7 +1101,7 @@ module Cask return unless new_cask? return if (url = cask.url).nil? - user, repo = get_repo_data(%r{https?://gitlab\.com/([^/]+)/([^/]+)/?.*}) + user, repo = get_repo_data(SharedAudits::NOTABILITY_REPOSITORY_REGEXES.fetch(:gitlab)) return if user.nil? || repo.nil? odebug "Auditing GitLab repo" @@ -1116,7 +1116,7 @@ module Cask return unless new_cask? return if (url = cask.url).nil? - user, repo = get_repo_data(%r{https?://bitbucket\.org/([^/]+)/([^/]+)/?.*}) + user, repo = get_repo_data(SharedAudits::NOTABILITY_REPOSITORY_REGEXES.fetch(:bitbucket)) return if user.nil? || repo.nil? odebug "Auditing Bitbucket repo" @@ -1131,7 +1131,7 @@ module Cask return unless new_cask? return if (url = cask.url).nil? - user, repo = get_repo_data(%r{https?://codeberg\.org/([^/]+)/([^/]+)/?.*}) + user, repo = get_repo_data(SharedAudits::NOTABILITY_REPOSITORY_REGEXES.fetch(:forgejo)) return if user.nil? || repo.nil? odebug "Auditing Forgejo repo" @@ -1200,6 +1200,16 @@ module Cask add_error new_domain_problem if new_domain_problem end + sig { void } + def audit_self_submission + return unless new_cask? + return unless online? + return unless cask.tap&.official? + + self_submission_problem = SharedAudits.self_submission_problem(self_submission_urls) + add_error self_submission_problem if self_submission_problem + end + sig { void } def audit_url_https_availability return unless online? @@ -1480,7 +1490,13 @@ module Cask def self_submission?(repo_owner) return false if repo_owner.empty? - SharedAudits.self_submission_for_repo_owner?(repo_owner) + SharedAudits.self_submission_for_repo_owner?(repo_owner) || + !SharedAudits.commit_email_domain_match(self_submission_urls).nil? + end + + sig { returns(T::Hash[Symbol, T.nilable(String)]) } + def self_submission_urls + { homepage: cask.homepage, url: cask.url&.to_s } end sig { diff --git a/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb b/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb index bc3c4d4890..94c18467f7 100644 --- a/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb +++ b/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb @@ -271,6 +271,8 @@ module Homebrew bitbucket_repository] end + audit_exceptions << "self_submission" if labels.include?("ci-skip-self-submission") + audit_exceptions << %w[token_valid token_bad_words token_conflicts] if labels.include?("ci-skip-token") audit_args << "--except" << audit_exceptions.join(",") if audit_exceptions.any? diff --git a/Library/Homebrew/formula_auditor.rb b/Library/Homebrew/formula_auditor.rb index 9eb3161db8..e054ab7ee4 100644 --- a/Library/Homebrew/formula_auditor.rb +++ b/Library/Homebrew/formula_auditor.rb @@ -681,6 +681,16 @@ module Homebrew problem new_domain_problem if new_domain_problem end + sig { void } + def audit_self_submission + return unless @core_tap + return unless @new_formula + return unless @online + + self_submission_problem = SharedAudits.self_submission_problem(self_submission_urls) + new_formula_problem self_submission_problem if self_submission_problem + end + sig { void } def audit_duplicate_formula return unless @core_tap @@ -806,7 +816,7 @@ module Homebrew sig { void } def audit_github_repository - user, repo = get_repo_data(%r{https?://github\.com/([^/]+)/([^/]+)/?.*}) if @new_formula + user, repo = get_repo_data(SharedAudits::NOTABILITY_REPOSITORY_REGEXES.fetch(:github)) if @new_formula return if user.blank? || repo.nil? @@ -819,7 +829,7 @@ module Homebrew sig { void } def audit_gitlab_repository - user, repo = get_repo_data(%r{https?://gitlab\.com/([^/]+)/([^/]+)/?.*}) if @new_formula + user, repo = get_repo_data(SharedAudits::NOTABILITY_REPOSITORY_REGEXES.fetch(:gitlab)) if @new_formula return if user.blank? || repo.nil? self_submission = self_submission?(user) @@ -831,7 +841,7 @@ module Homebrew sig { void } def audit_bitbucket_repository - user, repo = get_repo_data(%r{https?://bitbucket\.org/([^/]+)/([^/]+)/?.*}) if @new_formula + user, repo = get_repo_data(SharedAudits::NOTABILITY_REPOSITORY_REGEXES.fetch(:bitbucket)) if @new_formula return if user.blank? || repo.nil? self_submission = self_submission?(user) @@ -843,7 +853,7 @@ module Homebrew sig { void } def audit_forgejo_repository - user, repo = get_repo_data(%r{https?://codeberg\.org/([^/]+)/([^/]+)/?.*}) if @new_formula + user, repo = get_repo_data(SharedAudits::NOTABILITY_REPOSITORY_REGEXES.fetch(:forgejo)) if @new_formula return if user.blank? || repo.nil? self_submission = self_submission?(user) @@ -1362,7 +1372,13 @@ module Homebrew def self_submission?(repo_owner) return false if repo_owner.blank? - SharedAudits.self_submission_for_repo_owner?(repo_owner) + SharedAudits.self_submission_for_repo_owner?(repo_owner) || + !SharedAudits.commit_email_domain_match(self_submission_urls).nil? + end + + sig { returns(T::Hash[Symbol, T.nilable(String)]) } + def self_submission_urls + { homepage: formula.homepage, url: formula.stable&.url, head: formula.head&.url } end sig { params(formula: Formula).returns(T::Boolean) } diff --git a/Library/Homebrew/test/cask/audit_spec.rb b/Library/Homebrew/test/cask/audit_spec.rb index 35c7c6e5ba..b9a2079741 100644 --- a/Library/Homebrew/test/cask/audit_spec.rb +++ b/Library/Homebrew/test/cask/audit_spec.rb @@ -1699,6 +1699,60 @@ RSpec.describe Cask::Audit, :cask do end end + describe "self-submission" do + let(:only) { ["self_submission"] } + let(:new_cask) { true } + let(:self_submission_problem) { "Self-submitted (a commit author's email address matches brew.sh)" } + let(:cask) do + Cask::Cask.new("self-submission", tap: CoreCaskTap.instance) do + version "1.0" + sha256 :no_check + url "https://brew.sh/self-submission.tar.gz" + homepage "https://brew.sh" + end + end + + context "when a commit author's email address matches the homepage domain" do + before do + allow(SharedAudits).to receive(:self_submission_problem) + .with({ homepage: "https://brew.sh", url: "https://brew.sh/self-submission.tar.gz" }) + .and_return(self_submission_problem) + end + + it { is_expected.to error_with(self_submission_problem) } + end + + context "when the cask is not new" do + let(:new_cask) { false } + + it "does not check for self-submission" do + expect(SharedAudits).not_to receive(:self_submission_problem) + run + end + end + end + + describe "GitHub repository notability" do + let(:only) { ["github_repository"] } + let(:new_cask) { true } + let(:cask) do + Cask::Cask.new("github-repository", tap: CoreCaskTap.instance) do + version "1.0" + sha256 :no_check + url "https://github.com/acme/app/releases/download/1.0/app.dmg" + homepage "https://acme.io" + end + end + + it "applies the self-submission thresholds when a commit author's email address matches the homepage" do + allow(SharedAudits).to receive_messages(self_submission_for_repo_owner?: false, + pull_request_commit_email_domains: ["acme.io"]) + + expect(SharedAudits).to receive(:github).with("acme", "app", self_submission: true) + run + end + end + describe "GitHub repository archived check" do let(:online) { true } let(:only) { ["github_repository_archived"] } diff --git a/Library/Homebrew/test/formula_auditor_spec.rb b/Library/Homebrew/test/formula_auditor_spec.rb index 11e5cf6702..b0aed37f39 100644 --- a/Library/Homebrew/test/formula_auditor_spec.rb +++ b/Library/Homebrew/test/formula_auditor_spec.rb @@ -140,18 +140,18 @@ RSpec.describe Homebrew::FormulaAuditor do end end + def homepage_auditor(**options) + formula_auditor "foo", <<~RUBY, { core_tap: true, new_formula: true }.merge(options) + class Foo < Formula + homepage "https://brew.sh" + url "https://brew.sh/foo-1.0.tar.gz" + end + RUBY + end + describe "#audit_homepage_domain_age" do let(:domain_problem) { "`homepage` domain `brew.sh` was registered on 2026-07-01" } - def homepage_auditor(**options) - formula_auditor "foo", <<~RUBY, { core_tap: true, new_formula: true }.merge(options) - class Foo < Formula - homepage "https://brew.sh" - url "https://brew.sh/foo-1.0.tar.gz" - end - RUBY - end - it "reports newly registered homepage domains" do allow(SharedAudits).to receive(:new_domain_problem) .with("https://brew.sh").and_return(domain_problem) @@ -186,6 +186,28 @@ RSpec.describe Homebrew::FormulaAuditor do end end + describe "#audit_self_submission" do + let(:self_submission_problem) { "Self-submitted (a commit author's email address matches brew.sh)" } + + it "reports self-submissions without a git forge repository" do + allow(SharedAudits).to receive(:self_submission_problem) + .with({ homepage: "https://brew.sh", url: "https://brew.sh/foo-1.0.tar.gz", head: nil }) + .and_return(self_submission_problem) + + fa = homepage_auditor online: true + fa.audit_self_submission + + expect(fa.new_formula_problems.first[:message]).to eq(self_submission_problem) + end + + it "does not check existing formulae" do + fa = homepage_auditor new_formula: false + + expect(SharedAudits).not_to receive(:self_submission_problem) + fa.audit_self_submission + end + end + describe "#audit_license" do let(:spdx_license_data) { SPDX.license_data } let(:spdx_exception_data) { SPDX.exception_data } diff --git a/Library/Homebrew/test/utils/shared_audits_spec.rb b/Library/Homebrew/test/utils/shared_audits_spec.rb index e472be8058..b79b343e7a 100644 --- a/Library/Homebrew/test/utils/shared_audits_spec.rb +++ b/Library/Homebrew/test/utils/shared_audits_spec.rb @@ -245,6 +245,84 @@ RSpec.describe SharedAudits do end end + describe "::pull_request_commit_email_domains" do + let(:commits_url) { "https://api.github.com/repos/Homebrew/homebrew-cask/pulls/1/commits" } + + before { described_class.pull_request_commit_email_domains = nil } + after { described_class.pull_request_commit_email_domains = nil } + + it "returns the email domains of the pull request's commit authors" do + event_path = mktmpdir/"event.json" + event_path.write(JSON.generate({ pull_request: { commits_url: } })) + ENV["GITHUB_EVENT_PATH"] = event_path.to_s + first_page = [{ "commit" => { "author" => { "email" => "Jo@Acme.io" } } }] + second_page = [{ "commit" => { "author" => { "email" => "x@brew.sh" } } }] + allow(GitHub::API).to receive(:paginate_rest).with(commits_url) + .and_yield(first_page, 1).and_yield(second_page, 2) + + expect(described_class.pull_request_commit_email_domains).to eq(["acme.io", "brew.sh"]) + end + + it "returns no domains outside a pull request" do + ENV.delete("GITHUB_EVENT_PATH") + + expect(described_class.pull_request_commit_email_domains).to be_empty + end + end + + describe "::commit_email_domain_match" do + before { allow(described_class).to receive(:pull_request_commit_email_domains).and_return(["acme.io"]) } + + it "returns the stanza and domain at a commit author's email domain" do + expect(described_class.commit_email_domain_match({ homepage: "https://www.acme.io/app" })) + .to eq([:homepage, "acme.io"]) + end + + it "matches a subdomain of a commit author's email domain" do + urls = { homepage: "https://acmeapp.com", url: "https://dl.acme.io/app.dmg" } + + expect(described_class.commit_email_domain_match(urls)).to eq([:url, "dl.acme.io"]) + end + + it "ignores other domains" do + expect(described_class.commit_email_domain_match({ homepage: "https://notacme.io" })).to be_nil + end + + it "ignores git forge domains" do + allow(described_class).to receive(:pull_request_commit_email_domains).and_return(["github.com"]) + + expect(described_class.commit_email_domain_match({ homepage: "https://github.com/acme/app" })).to be_nil + end + end + + describe "::self_submission_problem" do + before { allow(described_class).to receive(:pull_request_commit_email_domains).and_return(["acme.io"]) } + + it "reports self-submissions without a repository to assess" do + urls = { homepage: "https://acmeapp.com", url: "https://dl.acme.io/app.dmg" } + + expect(described_class.self_submission_problem(urls)).to include("matches the `url` domain `dl.acme.io`") + end + + it "reports self-submissions whose git forge pages have no repository to assess" do + urls = { homepage: "https://acme.github.io", url: "https://acme.io/app.dmg" } + + expect(described_class.self_submission_problem(urls)).to include("matches the `url` domain `acme.io`") + end + + it "ignores git forge URLs embedded in other URLs" do + urls = { homepage: "https://acme.io", url: "https://dl.acme.io/app.dmg?source=https://github.com/acme/app" } + + expect(described_class.self_submission_problem(urls)).to include("matches the `homepage` domain `acme.io`") + end + + it "leaves self-submissions with a repository to the notability audits" do + urls = { homepage: "https://acme.io", url: "https://github.com/acme/app/releases/download/v1.0/app.dmg" } + + expect(described_class.self_submission_problem(urls)).to be_nil + end + end + describe "::github_tag_from_url" do it "finds tags in archive urls" do url = "https://github.com/a/b/archive/refs/tags/v1.2.3.tar.gz" diff --git a/Library/Homebrew/utils/shared_audits.rb b/Library/Homebrew/utils/shared_audits.rb index 0921f8c39d..2ada4f80ab 100644 --- a/Library/Homebrew/utils/shared_audits.rb +++ b/Library/Homebrew/utils/shared_audits.rb @@ -13,6 +13,12 @@ module SharedAudits GITLAB_NOTABILITY_THRESHOLDS = T.let({ forks: 30, stars: 75 }.freeze, T::Hash[Symbol, Integer]) BITBUCKET_NOTABILITY_THRESHOLDS = T.let({ forks: 30, watchers: 75 }.freeze, T::Hash[Symbol, Integer]) FORGEJO_NOTABILITY_THRESHOLDS = T.let({ forks: 30, watchers: 30, stars: 75 }.freeze, T::Hash[Symbol, Integer]) + NOTABILITY_REPOSITORY_REGEXES = T.let({ + github: %r{\Ahttps?://github\.com/([^/]+)/([^/]+)/?.*}, + gitlab: %r{\Ahttps?://gitlab\.com/([^/]+)/([^/]+)/?.*}, + bitbucket: %r{\Ahttps?://bitbucket\.org/([^/]+)/([^/]+)/?.*}, + forgejo: %r{\Ahttps?://codeberg\.org/([^/]+)/([^/]+)/?.*}, + }.freeze, T::Hash[Symbol, Regexp]) NEW_DOMAIN_THRESHOLD_DAYS = 30 GIT_FORGE_DOMAINS = %w[ bitbucket.org @@ -34,9 +40,16 @@ module SharedAudits @pull_request_author = T.let(nil, T.nilable(String)) @pull_request_author_computed = T.let(false, T::Boolean) @self_submission_cache = T.let({}, T::Hash[String, T::Boolean]) + @pull_request_commit_email_domains = T.let(nil, T.nilable(T::Array[String])) @rdap_services = T.let(nil, T.nilable(RdapServices)) class << self + sig { + params(pull_request_commit_email_domains: T.nilable(T::Array[String])) + .returns(T.nilable(T::Array[String])) + } + attr_writer :pull_request_commit_email_domains + sig { params(rdap_services: T.nilable(RdapServices)).returns(T.nilable(RdapServices)) } attr_writer :rdap_services end @@ -51,15 +64,8 @@ module SharedAudits sig { params(homepage: String).returns(T.nilable(String)) } def self.new_domain_problem(homepage) - host = begin - URI(homepage).host - rescue URI::InvalidURIError - nil - end - return if host.blank? - - domain = host.downcase.delete_prefix("www.") - return if GIT_FORGE_DOMAINS.any? { |forge| domain == forge || domain.end_with?(".#{forge}") } + domain = url_domain(homepage) + return if domain.nil? || git_forge_domain?(domain) @rdap_services ||= begin result = Utils::Curl.curl_output(RDAP_BOOTSTRAP_URL, max_time: RDAP_TIMEOUT_SECONDS) @@ -145,12 +151,50 @@ module SharedAudits return @pull_request_author if @pull_request_author_computed @pull_request_author_computed = true - github_event_path = ENV.fetch("GITHUB_EVENT_PATH", nil) - return @pull_request_author = nil if github_event_path.blank? + @pull_request_author = github_event_pull_request_value("user", "login") + end - @pull_request_author = JSON.parse(File.read(github_event_path)).dig("pull_request", "user", "login") - rescue Errno::ENOENT, JSON::ParserError - @pull_request_author = nil + sig { returns(T::Array[String]) } + def self.pull_request_commit_email_domains + @pull_request_commit_email_domains ||= begin + commits_url = github_event_pull_request_value("commits_url") + commits = [] + if commits_url.present? + GitHub::API.paginate_rest(commits_url) do |page_commits, _page| + commits.concat(page_commits) + end + end + commits.filter_map { |commit| commit.dig("commit", "author", "email").to_s.split("@").last&.downcase } + rescue GitHub::API::Error + [] + end + end + + sig { params(urls: T::Hash[Symbol, T.nilable(String)]).returns(T.nilable([Symbol, String])) } + def self.commit_email_domain_match(urls) + urls.each do |stanza, url| + next unless url + + domain = url_domain(url) + next if domain.nil? || git_forge_domain?(domain) + next if pull_request_commit_email_domains.none? { |email_domain| domain_or_subdomain?(domain, email_domain) } + + return [stanza, domain] + end + nil + end + + sig { params(urls: T::Hash[Symbol, T.nilable(String)]).returns(T.nilable(String)) } + def self.self_submission_problem(urls) + return if urls.values.compact.grep(Regexp.union(NOTABILITY_REPOSITORY_REGEXES.values)).any? + + match = commit_email_domain_match(urls) + return if match.nil? + + stanza, domain = match + "Self-submitted (a commit author's email address matches the `#{stanza}` domain `#{domain}`) " \ + "without a GitHub, GitLab, Bitbucket or Codeberg repository to assess its notability. " \ + "This is not an automatic rejection, but the pull request author must provide evidence that it is notable." end sig { params(submitter: T.nilable(String), repo_owner: String).returns(T::Boolean) } @@ -181,6 +225,36 @@ module SharedAudits thresholds.transform_values { |value| value * SELF_SUBMISSION_THRESHOLD_MULTIPLIER } end + sig { params(keys: String).returns(T.nilable(String)) } + private_class_method def self.github_event_pull_request_value(*keys) + github_event_path = ENV.fetch("GITHUB_EVENT_PATH", nil) + return if github_event_path.blank? + + JSON.parse(File.read(github_event_path)).dig("pull_request", *keys) + rescue Errno::ENOENT, JSON::ParserError + nil + end + + sig { params(url: String).returns(T.nilable(String)) } + private_class_method def self.url_domain(url) + host = begin + URI(url).host + rescue URI::InvalidURIError + nil + end + host.downcase.delete_prefix("www.") if host.present? + end + + sig { params(domain: String).returns(T::Boolean) } + private_class_method def self.git_forge_domain?(domain) + GIT_FORGE_DOMAINS.any? { |forge| domain_or_subdomain?(domain, forge) } + end + + sig { params(domain: String, parent: String).returns(T::Boolean) } + private_class_method def self.domain_or_subdomain?(domain, parent) + domain == parent || domain.end_with?(".#{parent}") + end + sig { params(product: String, cycle: String).returns(T.nilable(T::Hash[String, T.untyped])) } def self.eol_data(product, cycle) @eol_data ||= T.let({}, T.nilable(T::Hash[String, T.untyped])) diff --git a/docs/Package-Acceptance-Policy.md b/docs/Package-Acceptance-Policy.md index 19eb6ad73c..353c4ccda1 100644 --- a/docs/Package-Acceptance-Policy.md +++ b/docs/Package-Acceptance-Policy.md @@ -29,9 +29,10 @@ A new package must demonstrate public interest beyond its author. A GitHub project normally satisfies this requirement by meeting one of these thresholds: * at least 30 forks, 30 watchers or 75 stars. -* at least 90 forks, 90 watchers or 225 stars for a self-submission by the repository owner. +* at least 90 forks, 90 watchers or 225 stars for a self-submission by the repository owner or by a commit author whose email address is at the package's `homepage`, `url` or `head` domain. Equivalent public evidence may be considered for software hosted elsewhere. +A self-submission without a public code repository is not automatically rejected, but its author must provide evidence of notability in the pull request. The metrics apply to the canonical upstream repository, not an unendorsed mirror or code-hosting fork. A code repository less than 30 days old is normally not eligible. Repository-specific exceptions may apply when these metrics do not represent the software's actual use or maintenance prospects.