254 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			254 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
| # frozen_string_literal: true
 | |
| 
 | |
| require "download_strategy"
 | |
| require "cli/parser"
 | |
| require "utils/github"
 | |
| require "tmpdir"
 | |
| require "bintray"
 | |
| 
 | |
| module Homebrew
 | |
|   module_function
 | |
| 
 | |
|   def pr_pull_args
 | |
|     Homebrew::CLI::Parser.new do
 | |
|       usage_banner <<~EOS
 | |
|         `pr-pull` [<options>] <pull_request> [<pull_request> ...]
 | |
| 
 | |
|         Download and publish bottles, and apply the bottle commit from a
 | |
|         pull request with artifacts generated by GitHub Actions.
 | |
|         Requires write access to the repository.
 | |
|       EOS
 | |
|       switch "--no-publish",
 | |
|              description: "Download the bottles, apply the bottle commit and "\
 | |
|                           "upload the bottles to Bintray, but don't publish them."
 | |
|       switch "--no-upload",
 | |
|              description: "Download the bottles and apply the bottle commit, "\
 | |
|                           "but don't upload to Bintray."
 | |
|       switch "-n", "--dry-run",
 | |
|              description: "Print what would be done rather than doing it."
 | |
|       switch "--clean",
 | |
|              description: "Do not amend the commits from pull requests."
 | |
|       switch "--branch-okay",
 | |
|              description: "Do not warn if pulling to a branch besides master (useful for testing)."
 | |
|       switch "--resolve",
 | |
|              description: "When a patch fails to apply, leave in progress and allow user to resolve, "\
 | |
|                           "instead of aborting."
 | |
|       flag   "--workflow=",
 | |
|              description: "Retrieve artifacts from the specified workflow (default: tests.yml)."
 | |
|       flag   "--artifact=",
 | |
|              description: "Download artifacts with the specified name (default: bottles)."
 | |
|       flag   "--bintray-org=",
 | |
|              description: "Upload to the specified Bintray organisation (default: homebrew)."
 | |
|       flag   "--tap=",
 | |
|              description: "Target tap repository (default: homebrew/core)."
 | |
|       switch :verbose
 | |
|       switch :debug
 | |
|       min_named 1
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def setup_git_environment!
 | |
|     # Passthrough Git environment variables
 | |
|     ENV["GIT_COMMITTER_NAME"] = ENV["HOMEBREW_GIT_NAME"] if ENV["HOMEBREW_GIT_NAME"]
 | |
|     ENV["GIT_COMMITTER_EMAIL"] = ENV["HOMEBREW_GIT_EMAIL"] if ENV["HOMEBREW_GIT_EMAIL"]
 | |
| 
 | |
|     # Depending on user configuration, git may try to invoke gpg.
 | |
|     return unless Utils.popen_read("git config --get --bool commit.gpgsign").chomp == "true"
 | |
| 
 | |
|     begin
 | |
|       gnupg = Formula["gnupg"]
 | |
|     rescue FormulaUnavailableError
 | |
|       nil
 | |
|     else
 | |
|       if gnupg.installed?
 | |
|         path = PATH.new(ENV.fetch("PATH"))
 | |
|         path.prepend(gnupg.installed_prefix/"bin")
 | |
|         ENV["PATH"] = path
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def signoff!(pr, path: ".")
 | |
|     message = Utils.popen_read "git", "-C", path, "log", "-1", "--pretty=%B"
 | |
|     close_message = "Closes ##{pr}."
 | |
|     message += "\n#{close_message}" unless message.include? close_message
 | |
|     if Homebrew.args.dry_run?
 | |
|       puts "git commit --amend --signoff -m $message"
 | |
|     else
 | |
|       safe_system "git", "-C", path, "commit", "--amend", "--signoff", "--allow-empty", "-q", "-m", message
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def cherry_pick_pr!(pr, path: ".")
 | |
|     if Homebrew.args.dry_run?
 | |
|       puts <<~EOS
 | |
|         git fetch --force origin +refs/pull/#{pr}/head
 | |
|         git merge-base HEAD FETCH_HEAD
 | |
|         git cherry-pick --ff --allow-empty $merge_base..FETCH_HEAD
 | |
|       EOS
 | |
|     else
 | |
|       safe_system "git", "-C", path, "fetch", "--quiet", "--force", "origin", "+refs/pull/#{pr}/head"
 | |
|       merge_base = Utils.popen_read("git", "-C", path, "merge-base", "HEAD", "FETCH_HEAD").strip
 | |
|       commit_count = Utils.popen_read("git", "-C", path, "rev-list", "#{merge_base}..FETCH_HEAD").lines.count
 | |
| 
 | |
|       # git cherry-pick unfortunately has no quiet option
 | |
|       ohai "Cherry-picking #{commit_count} commit#{"s" unless commit_count == 1} from ##{pr}"
 | |
|       cherry_pick_args = "git", "-C", path, "cherry-pick", "--ff", "--allow-empty", "#{merge_base}..FETCH_HEAD"
 | |
|       result = Homebrew.args.verbose? ? system(*cherry_pick_args) : quiet_system(*cherry_pick_args)
 | |
| 
 | |
|       unless result
 | |
|         if Homebrew.args.resolve?
 | |
|           odie "Cherry-pick failed: try to resolve it."
 | |
|         else
 | |
|           system "git", "-C", path, "cherry-pick", "--abort"
 | |
|           odie "Cherry-pick failed!"
 | |
|         end
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def check_branch(path, ref)
 | |
|     branch = Utils.popen_read("git", "-C", path, "symbolic-ref", "--short", "HEAD").strip
 | |
| 
 | |
|     return if branch == ref || args.clean? || args.branch_okay?
 | |
| 
 | |
|     opoo "Current branch is #{branch}: do you need to pull inside #{ref}?"
 | |
|   end
 | |
| 
 | |
|   def formulae_need_bottles?(tap, original_commit)
 | |
|     return if Homebrew.args.dry_run?
 | |
| 
 | |
|     if Homebrew::EnvConfig.disable_load_formula?
 | |
|       opoo "Can't check if updated bottles are necessary as formula loading is disabled!"
 | |
|       return
 | |
|     end
 | |
| 
 | |
|     Utils.popen_read("git", "-C", tap.path, "diff-tree",
 | |
|                      "-r", "--name-only", "--diff-filter=AM",
 | |
|                      original_commit, "HEAD", "--", tap.formula_dir)
 | |
|          .lines.each do |line|
 | |
|       next unless line.end_with? ".rb\n"
 | |
| 
 | |
|       name = "#{tap.name}/#{File.basename(line.chomp, ".rb")}"
 | |
|       begin
 | |
|         f = Formula[name]
 | |
|       rescue Exception # rubocop:disable Lint/RescueException
 | |
|         # Make sure we catch syntax errors.
 | |
|         next
 | |
|       end
 | |
|       return true if !f.bottle_unneeded? && !f.bottle_disabled?
 | |
|     end
 | |
|     nil
 | |
|   end
 | |
| 
 | |
|   def download_artifact(url, dir, pr)
 | |
|     token, username = GitHub.api_credentials
 | |
|     case GitHub.api_credentials_type
 | |
|     when :env_username_password, :keychain_username_password
 | |
|       curl_args = ["--user", "#{username}:#{token}"]
 | |
|     when :env_token
 | |
|       curl_args = ["--header", "Authorization: token #{token}"]
 | |
|     when :none
 | |
|       raise Error, "Credentials must be set to access the Artifacts API"
 | |
|     end
 | |
| 
 | |
|     # Download the artifact as a zip file and unpack it into `dir`. This is
 | |
|     # preferred over system `curl` and `tar` as this leverages the Homebrew
 | |
|     # cache to avoid repeated downloads of (possibly large) bottles.
 | |
|     FileUtils.chdir dir do
 | |
|       downloader = GitHubArtifactDownloadStrategy.new(url, "artifact", pr, curl_args: curl_args, secrets: [token])
 | |
|       downloader.fetch
 | |
|       downloader.stage
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   def pr_pull
 | |
|     pr_pull_args.parse
 | |
| 
 | |
|     bintray_user = ENV["HOMEBREW_BINTRAY_USER"]
 | |
|     bintray_key = ENV["HOMEBREW_BINTRAY_KEY"]
 | |
|     bintray_org = args.bintray_org || "homebrew"
 | |
| 
 | |
|     if bintray_user.blank? || bintray_key.blank?
 | |
|       odie "Missing HOMEBREW_BINTRAY_USER or HOMEBREW_BINTRAY_KEY variables!" if !args.dry_run? && !args.no_upload?
 | |
|     else
 | |
|       bintray = Bintray.new(user: bintray_user, key: bintray_key, org: bintray_org)
 | |
|     end
 | |
| 
 | |
|     workflow = args.workflow || "tests.yml"
 | |
|     artifact = args.artifact || "bottles"
 | |
|     tap = Tap.fetch(args.tap || CoreTap.instance.name)
 | |
| 
 | |
|     setup_git_environment!
 | |
| 
 | |
|     args.named.uniq.each do |arg|
 | |
|       arg = "#{tap.default_remote}/pull/#{arg}" if arg.to_i.positive?
 | |
|       url_match = arg.match HOMEBREW_PULL_OR_COMMIT_URL_REGEX
 | |
|       _, user, repo, pr = *url_match
 | |
|       odie "Not a GitHub pull request: #{arg}" unless pr
 | |
| 
 | |
|       check_branch tap.path, "master"
 | |
| 
 | |
|       ohai "Fetching #{tap} pull request ##{pr}"
 | |
|       Dir.mktmpdir pr do |dir|
 | |
|         cd dir do
 | |
|           original_commit = Utils.popen_read("git", "-C", tap.path, "rev-parse", "HEAD").chomp
 | |
|           cherry_pick_pr! pr, path: tap.path
 | |
|           signoff! pr, path: tap.path unless args.clean?
 | |
| 
 | |
|           unless formulae_need_bottles? tap, original_commit
 | |
|             ohai "Skipping artifacts for ##{pr} as the formulae don't need bottles"
 | |
|             next
 | |
|           end
 | |
| 
 | |
|           url = GitHub.get_artifact_url(user, repo, pr, workflow_id: workflow, artifact_name: artifact)
 | |
|           download_artifact(url, dir, pr)
 | |
| 
 | |
|           if Homebrew.args.dry_run?
 | |
|             puts "brew bottle --merge --write #{Dir["*.json"].join " "}"
 | |
|           else
 | |
|             quiet_system "#{HOMEBREW_PREFIX}/bin/brew", "bottle", "--merge", "--write", *Dir["*.json"]
 | |
|           end
 | |
| 
 | |
|           next if args.no_upload?
 | |
| 
 | |
|           if Homebrew.args.dry_run?
 | |
|             puts "Upload bottles described by these JSON files to Bintray:\n  #{Dir["*.json"].join("\n  ")}"
 | |
|           else
 | |
|             bintray.upload_bottle_json Dir["*.json"], publish_package: !args.no_publish?
 | |
|           end
 | |
|         end
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| end
 | |
| 
 | |
| class GitHubArtifactDownloadStrategy < AbstractFileDownloadStrategy
 | |
|   def fetch
 | |
|     ohai "Downloading #{url}"
 | |
|     if cached_location.exist?
 | |
|       puts "Already downloaded: #{cached_location}"
 | |
|     else
 | |
|       begin
 | |
|         curl "--location", "--create-dirs", "--output", temporary_path, url,
 | |
|              *meta.fetch(:curl_args, []),
 | |
|              secrets: meta.fetch(:secrets, [])
 | |
|       rescue ErrorDuringExecution
 | |
|         raise CurlDownloadStrategyError, url
 | |
|       end
 | |
|       ignore_interrupts do
 | |
|         cached_location.dirname.mkpath
 | |
|         temporary_path.rename(cached_location)
 | |
|         symlink_location.dirname.mkpath
 | |
|       end
 | |
|     end
 | |
|     FileUtils.ln_s cached_location.relative_path_from(symlink_location.dirname), symlink_location, force: true
 | |
|   end
 | |
| 
 | |
|   private
 | |
| 
 | |
|   def resolved_basename
 | |
|     "artifact.zip"
 | |
|   end
 | |
| end
 | 
