diff --git a/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb b/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb new file mode 100644 index 0000000000000..3fb7949f17ab6 --- /dev/null +++ b/Library/Homebrew/dev-cmd/generate-cask-ci-matrix.rb @@ -0,0 +1,351 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "tap" +require "utils/github/api" +require "cli/parser" +require "system_command" + +module Homebrew + module DevCmd + class GenerateCaskCiMatrix < AbstractCommand + MAX_JOBS = 256 + + # Weight for each arch must add up to 1.0. + INTEL_RUNNERS = T.let({ + { symbol: :ventura, name: "macos-13", arch: :intel } => 1.0, + }.freeze, T::Hash[T::Hash[Symbol, T.any(Symbol, String)], Float]) + ARM_RUNNERS = T.let({ + { symbol: :sonoma, name: "macos-14", arch: :arm } => 0.0, + { symbol: :sequoia, name: "macos-15", arch: :arm } => 1.0, + }.freeze, T::Hash[T::Hash[Symbol, T.any(Symbol, String)], Float]) + RUNNERS = T.let(INTEL_RUNNERS.merge(ARM_RUNNERS).freeze, + T::Hash[T::Hash[Symbol, T.any(Symbol, String)], Float]) + + cmd_args do + description <<~EOS + Generate a GitHub Actions matrix for a given pull request URL or list of cask names. + For internal use in Homebrew taps. + EOS + + switch "--url", + description: "Treat named argument as a pull request URL." + switch "--cask", "--casks", + description: "Treat all named arguments as cask tokens." + switch "--skip-install", + description: "Skip installing casks" + switch "--new", + description: "Run new cask checks" + switch "--syntax-only", + description: "Only run syntax checks" + + conflicts "--url", "--cask" + conflicts "--syntax-only", "--skip-install" + conflicts "--syntax-only", "--new" + + named_args [:cask, :url], min: 1 + hide_from_man_page! + end + + sig { params(args: T::Array[String]).void } + def initialize(*args) + super + repository = ENV.fetch("GITHUB_REPOSITORY", nil) + raise UsageError, "The GITHUB_REPOSITORY environment variable must be set." if repository.blank? + + @tap = T.let(Tap.fetch(repository), Tap) + end + + sig { override.void } + def run + skip_install = args.skip_install? + new_cask = args.new? + casks = args.named if args.casks? + pr_url = args.named if args.url? + syntax_only = args.syntax_only? + tap = @tap + + puts "Generating matrix for #{casks}..." + + raise UsageError, "This command must be run from inside a tap directory." if Dir.pwd.to_s != tap.path.to_s + + labels = if pr_url + pr = GitHub::API.open_rest(pr_url) + pr.fetch("labels").map { |l| l.fetch("name") } + else + [] + end + + runner = random_runner[:name] + syntax_job = { + name: "syntax", + tap: tap.name, + runner:, + } + + matrix = [syntax_job] + + if !syntax_only && !labels&.include?("ci-syntax-only") + cask_jobs = if casks&.any? + generate_matrix(tap, labels:, cask_names: casks, skip_install:, new_cask:) + else + generate_matrix(tap, labels:, skip_install:, new_cask:) + end + + if cask_jobs.any? + # If casks were changed, skip `audit` for whole tap. + syntax_job[:skip_audit] = true + + # The syntax job only runs `style` at this point, which should work on Linux. + # Running on macOS is currently faster though, since `homebrew/cask` and + # `homebrew/core` are already tapped on macOS CI machines. + # syntax_job[:runner] = "ubuntu-latest" + end + + matrix += cask_jobs + end + + syntax_job[:name] += " (#{syntax_job[:runner]})" + + puts JSON.pretty_generate(matrix) + github_output = ENV.fetch("GITHUB_OUTPUT", nil) + return unless github_output + + File.open(ENV.fetch("GITHUB_OUTPUT"), "a") do |f| + f.puts "matrix=#{JSON.generate(matrix)}" + end + end + + sig { params(cask_content: String).returns(T::Hash[T::Hash[Symbol, T.any(Symbol, String)], Float]) } + def filter_runners(cask_content) + # Retrieve arguments from `depends_on macos:` + required_macos = case cask_content + when /depends_on\s+macos:\s+\[([^\]]+)\]/ + T.must(Regexp.last_match(1)).scan(/\s*(?:"([=<>]=)\s+)?:([^\s",]+)"?,?\s*/).map do |match| + { + version: T.must(match[1]).to_sym, + comparator: match[0] || "==", + } + end + when /depends_on\s+macos:\s+"?:([^\s"]+)"?/ # e.g. `depends_on macos: :big_sur` + [ + { + version: T.must(Regexp.last_match(1)).to_sym, + comparator: "==", + }, + ] + when /depends_on\s+macos:\s+"([=<>]=)\s+:([^\s"]+)"/ # e.g. `depends_on macos: ">= :monterey"` + [ + { + version: T.must(Regexp.last_match(2)).to_sym, + comparator: Regexp.last_match(1), + }, + ] + when /depends_on\s+macos:/ + # In this case, `depends_on macos:` is present but wasn't matched by the + # previous regexes. We want this to visibly fail so we can address the + # shortcoming instead of quietly defaulting to `RUNNERS`. + odie "Unhandled `depends_on macos` argument" + else + [] + end + + filtered_runners = RUNNERS.select do |runner, _| + required_macos.any? do |r| + MacOSVersion.from_symbol(runner.fetch(:symbol).to_sym).compare( + r.fetch(:comparator), + MacOSVersion.from_symbol(r.fetch(:version).to_sym), + ) + end + end + filtered_runners = RUNNERS.dup if filtered_runners.empty? + + archs = architectures(cask_content:) + filtered_runners.select! do |runner, _| + archs.include?(runner.fetch(:arch)) + end + + RUNNERS + end + + sig { params(cask_content: BasicObject).returns(T::Array[Symbol]) } + def architectures(cask_content:) + case cask_content + when /depends_on\s+arch:\s+:arm64/ + [:arm] + when /depends_on\s+arch:\s+:x86_64/ + [:intel] + when /\barch\b/, /\bon_(arm|intel)\b/ + [:arm, :intel] + else + RUNNERS.keys.map { |r| r.fetch(:arch) }.uniq.sort + end + end + + sig { + params(available_runners: T::Hash[T::Hash[Symbol, T.any(Symbol, String)], + Float]).returns(T::Hash[Symbol, T.any(Symbol, String)]) + } + def random_runner(available_runners = ARM_RUNNERS) + T.must(available_runners.max_by { |(_, weight)| rand ** (1.0 / weight) }) + .first + end + + sig { params(cask_content: String).returns([T::Array[T::Hash[Symbol, T.any(Symbol, String)]], T::Boolean]) } + def runners(cask_content:) + filtered_runners = filter_runners(cask_content) + + macos_version_found = cask_content.match?(/\bMacOS\s*\.version\b/m) + filtered_macos_found = filtered_runners.keys.any? do |runner| + ( + macos_version_found && + cask_content.include?(runner[:symbol].inspect) + ) || cask_content.include?("on_#{runner[:symbol]}") + end + + if filtered_macos_found + # If the cask varies on a MacOS version, test it on every possible macOS version. + [filtered_runners.keys, true] + else + # Otherwise, select a runner from each architecture based on weighted random sample. + grouped_runners = filtered_runners.group_by { |runner, _| runner.fetch(:arch) } + selected_runners = grouped_runners.map do |_, runners| + random_runner(runners.to_h) + end + [selected_runners, false] + end + end + + sig { + params(tap: T.nilable(Tap), labels: T::Array[String], cask_names: T::Array[String], skip_install: T::Boolean, + new_cask: T::Boolean).returns(T::Array[T::Hash[Symbol, + T.any(String, T::Boolean, T::Array[String])]]) + } + def generate_matrix(tap, labels: [], cask_names: [], skip_install: false, new_cask: false) + odie "This command must be run from inside a tap directory." unless tap + + changed_files = find_changed_files + + ruby_files_in_wrong_directory = + T.must(changed_files[:modified_ruby_files]) - ( + T.must(changed_files[:modified_cask_files]) + + T.must(changed_files[:modified_command_files]) + + T.must(changed_files[:modified_github_actions_files]) + ) + + if ruby_files_in_wrong_directory.any? + ruby_files_in_wrong_directory.each do |path| + puts "::error file=#{path}::File is in wrong directory." + end + + odie "Found Ruby files in wrong directory:\n#{ruby_files_in_wrong_directory.join("\n")}" + end + + cask_files_to_check = if cask_names.any? + cask_names.map do |cask_name| + Cask::CaskLoader.find_cask_in_tap(cask_name, tap).relative_path_from(tap.path) + end + else + T.must(changed_files[:modified_cask_files]) + end + + jobs = cask_files_to_check.count + odie "Maximum job matrix size exceeded: #{jobs}/#{MAX_JOBS}" if jobs > MAX_JOBS + + cask_files_to_check.flat_map do |path| + cask_token = path.basename(".rb") + + audit_args = ["--online"] + audit_args << "--new" if T.must(changed_files[:added_files]).include?(path) || new_cask + + audit_args << "--signing" + + audit_exceptions = [] + + audit_exceptions << %w[homepage_https_availability] if labels.include?("ci-skip-homepage") + + if labels.include?("ci-skip-livecheck") + audit_exceptions << %w[hosting_with_livecheck livecheck_https_availability + livecheck_min_os livecheck_version] + end + + audit_exceptions << "livecheck_min_os" if labels.include?("ci-skip-livecheck-min-os") + + if labels.include?("ci-skip-repository") + audit_exceptions << %w[github_repository github_prerelease_version + gitlab_repository gitlab_prerelease_version + bitbucket_repository] + end + + if labels.include?("ci-skip-token") + audit_exceptions << %w[token_conflicts token_valid + token_bad_words] + end + + audit_args << "--except" << audit_exceptions.join(",") if audit_exceptions.any? + + cask_content = path.read + + runners, multi_os = runners(cask_content:) + runners.product(architectures(cask_content:)).filter_map do |runner, arch| + native_runner_arch = arch == runner.fetch(:arch) + # If it's just a single OS test then we can just use the two real arch runners. + next if !native_runner_arch && !multi_os + + arch_args = native_runner_arch ? [] : ["--arch=#{arch}"] + { + name: "test #{cask_token} (#{runner.fetch(:name)}, #{arch})", + tap: tap.name, + cask: { + token: cask_token, + path: "./#{path}", + }, + audit_args: audit_args + arch_args, + fetch_args: arch_args, + skip_install: labels.include?("ci-skip-install") || !native_runner_arch || skip_install, + runner: runner.fetch(:name), + } + end + end + end + + sig { returns(T::Hash[Symbol, T::Array[String]]) } + def find_changed_files + tap = @tap + + commit_range_start = Utils.safe_popen_read("git", "rev-parse", "origin").chomp + commit_range_end = Utils.safe_popen_read("git", "rev-parse", "HEAD").chomp + commit_range = "#{commit_range_start}...#{commit_range_end}" + + modified_files = Utils.safe_popen_read("git", "diff", "--name-only", "--diff-filter=AMR", commit_range) + .split("\n") + .map do |path| + Pathname(path) + end + + added_files = Utils.safe_popen_read("git", "diff", "--name-only", "--diff-filter=A", commit_range) + .split("\n") + .map do |path| + Pathname(path) + end + + modified_ruby_files = modified_files.select { |path| path.extname == ".rb" } + modified_command_files = modified_files.select { |path| path.ascend.to_a.last.to_s == "cmd" } + modified_github_actions_files = modified_files.select do |path| + path.to_s.start_with?(".github/actions/") + end + modified_cask_files = modified_files.select { |path| tap.cask_file?(path.to_s) } + + { + modified_files:, + added_files:, + modified_ruby_files:, + modified_command_files:, + modified_github_actions_files:, + modified_cask_files:, + } + end + end + end +end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/generate_cask_ci_matrix.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/generate_cask_ci_matrix.rbi new file mode 100644 index 0000000000000..a0397c4e8b5d7 --- /dev/null +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/generate_cask_ci_matrix.rbi @@ -0,0 +1,31 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for dynamic methods in `Homebrew::DevCmd::GenerateCaskCiMatrix`. +# Please instead update this file by running `bin/tapioca dsl Homebrew::DevCmd::GenerateCaskCiMatrix`. + + +class Homebrew::DevCmd::GenerateCaskCiMatrix + sig { returns(Homebrew::DevCmd::GenerateCaskCiMatrix::Args) } + def args; end +end + +class Homebrew::DevCmd::GenerateCaskCiMatrix::Args < Homebrew::CLI::Args + sig { returns(T::Boolean) } + def cask?; end + + sig { returns(T::Boolean) } + def casks?; end + + sig { returns(T::Boolean) } + def new?; end + + sig { returns(T::Boolean) } + def skip_install?; end + + sig { returns(T::Boolean) } + def syntax_only?; end + + sig { returns(T::Boolean) } + def url?; end +end diff --git a/Library/Homebrew/test/dev-cmd/generate-cask-ci-matrix_spec.rb b/Library/Homebrew/test/dev-cmd/generate-cask-ci-matrix_spec.rb new file mode 100644 index 0000000000000..490c5cc83add8 --- /dev/null +++ b/Library/Homebrew/test/dev-cmd/generate-cask-ci-matrix_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "cmd/shared_examples/args_parse" +require "dev-cmd/generate-cask-ci-matrix" + +RSpec.describe Homebrew::DevCmd::GenerateCaskCiMatrix do + ENV["GITHUB_REPOSITORY"] = "homebrew/homebrew-cask" + + it_behaves_like "parseable arguments" + + it "creates a runner matrix", :integration_test do + expect do + brew "determine-cask-runners", "google-chrome", + "GITHUB_REPOSITORY" => "homebrew/homebrew-cask" + end + .to output(/Error: Invalid usage: This command must be run from inside a tap directory./).to_stderr + end +end