# 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` [] [ ...] 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.any_version_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" subject = message.lines.first.strip # Skip the subject and separate lines that look like trailers (e.g. "Co-authored-by") # from lines that look like regular body text. trailers, body = message.lines.drop(1).partition { |s| s.match?(/^[a-z-]+-by:/i) } trailers = trailers.uniq.join.strip body = body.join.strip.gsub(/\n{3,}/, "\n\n") close_message = "Closes ##{pr}." body += "\n\n#{close_message}" unless body.include? close_message new_message = [subject, body, trailers].join("\n\n").strip 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", new_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) json_files = Dir["*.json"] if Homebrew.args.dry_run? puts "brew bottle --merge --write #{json_files.join " "}" else quiet_system "#{HOMEBREW_PREFIX}/bin/brew", "bottle", "--merge", "--write", *json_files end next if args.no_upload? if Homebrew.args.dry_run? puts "Upload bottles described by these JSON files to Bintray:\n #{json_files.join("\n ")}" else bintray.upload_bottle_json json_files, publish_package: !args.no_publish? end bottles_hash = json_files.reduce({}) do |hash, json_file| hash.deep_merge(JSON.parse(IO.read(json_file))) end bottles_hash.each do |formula_name, _| formula = Formula[formula_name] stable_urls = [formula.stable.url] + formula.stable.mirrors stable_urls.grep(%r{^https://dl.bintray.com/homebrew/mirror/}) do |mirror_url| if Homebrew.args.dry_run? puts "Mirror formulae sources described by these JSON files to Bintray:\n #{json_files.join("\n ")}" next end next if bintray.stable_mirrored?(mirror_url) mirror_url = bintray.mirror_formula(formula) ohai "Mirrored #{formula.full_name} to #{mirror_url}!" end 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