bintray.rb 9.54 KB
Newer Older
1
# typed: false
2
3
4
5
6
# frozen_string_literal: true

require "utils/curl"
require "json"

Markus Reiter's avatar
Markus Reiter committed
7
8
9
# Bintray API client.
#
# @api private
10
class Bintray
Markus Reiter's avatar
Markus Reiter committed
11
12
  extend T::Sig

13
  include Context
Jonathan Chang's avatar
Jonathan Chang committed
14
  include Utils::Curl
15

16
17
18
19
20
  API_URL = "https://api.bintray.com"

  class Error < RuntimeError
  end

Markus Reiter's avatar
Markus Reiter committed
21
  sig { returns(String) }
22
  def inspect
23
    "#<Bintray: org=#{@bintray_org}>"
24
25
  end

Jonathan Chang's avatar
Jonathan Chang committed
26
  sig { params(org: T.nilable(String)).void }
27
  def initialize(org: "homebrew")
28
    @bintray_org = org
29

30
31
    raise UsageError, "Must set a Bintray organisation!" unless @bintray_org

32
33
34
    ENV["HOMEBREW_FORCE_HOMEBREW_ON_LINUX"] = "1" if @bintray_org == "homebrew" && !OS.mac?
  end

Jonathan Chang's avatar
Jonathan Chang committed
35
  def open_api(url, *args, auth: true)
36
    if auth
37
38
      raise UsageError, "HOMEBREW_BINTRAY_USER is unset." unless (user = Homebrew::EnvConfig.bintray_user)
      raise UsageError, "HOMEBREW_BINTRAY_KEY is unset." unless (key = Homebrew::EnvConfig.bintray_key)
39
40
41
42

      args += ["--user", "#{user}:#{key}"]
    end

Jonathan Chang's avatar
Jonathan Chang committed
43
    curl(*args, url, print_stdout: false, secrets: key)
44
45
  end

Jonathan Chang's avatar
Jonathan Chang committed
46
47
48
49
50
51
52
53
54
  sig do
    params(local_file:    String,
           repo:          String,
           package:       String,
           version:       String,
           remote_file:   String,
           sha256:        T.nilable(String),
           warn_on_error: T.nilable(T::Boolean)).void
  end
55
56
57
58
59
60
61
62
63
64
  def upload(local_file, repo:, package:, version:, remote_file:, sha256: nil, warn_on_error: false)
    unless File.exist? local_file
      msg = "#{local_file} for upload doesn't exist!"
      raise Error, msg unless warn_on_error

      # Warn and return early here since we know this upload is going to fail.
      opoo msg
      return
    end

65
    url = "#{API_URL}/content/#{@bintray_org}/#{repo}/#{package}/#{version}/#{remote_file}"
66
    args = ["--upload-file", local_file]
67
    args += ["--header", "X-Checksum-Sha2: #{sha256}"] unless sha256.blank?
68
    args << "--fail" unless warn_on_error
Jonathan Chang's avatar
Jonathan Chang committed
69
70

    result = T.unsafe(self).open_api(url, *args)
71

72
    json = JSON.parse(result.stdout)
Jonathan Chang's avatar
Jonathan Chang committed
73
    return if json["message"] == "success"
74

Jonathan Chang's avatar
Jonathan Chang committed
75
76
    msg = "Bottle upload failed: #{json["message"]}"
    raise msg unless warn_on_error
77

Jonathan Chang's avatar
Jonathan Chang committed
78
    opoo msg
79
80
  end

Jonathan Chang's avatar
Jonathan Chang committed
81
82
83
84
85
86
87
  sig do
    params(repo:          String,
           package:       String,
           version:       String,
           file_count:    T.nilable(Integer),
           warn_on_error: T.nilable(T::Boolean)).void
  end
88
  def publish(repo:, package:, version:, file_count:, warn_on_error: false)
89
    url = "#{API_URL}/content/#{@bintray_org}/#{repo}/#{package}/#{version}/publish"
90
    upload_args = %w[--request POST]
Jonathan Chang's avatar
Jonathan Chang committed
91
92
    upload_args += ["--fail"] unless warn_on_error
    result = T.unsafe(self).open_api(url, *upload_args)
93
    json = JSON.parse(result.stdout)
Jonathan Chang's avatar
Jonathan Chang committed
94
    if file_count.present? && json["files"] != file_count
95
96
97
98
      message = "Bottle publish failed: expected #{file_count} bottles, but published #{json["files"]} instead."
      raise message unless warn_on_error

      opoo message
99
100
101
    end

    odebug "Published #{json["files"]} bottles"
102
103
  end

Jonathan Chang's avatar
Jonathan Chang committed
104
  sig { params(org: T.nilable(String)).returns(T::Boolean) }
105
106
107
108
  def official_org?(org: @bintray_org)
    %w[homebrew linuxbrew].include? org
  end

Jonathan Chang's avatar
Jonathan Chang committed
109
  sig { params(url: String).returns(T::Boolean) }
110
111
112
113
114
115
  def stable_mirrored?(url)
    headers, = curl_output("--connect-timeout", "15", "--location", "--head", url)
    status_code = headers.scan(%r{^HTTP/.* (\d+)}).last.first
    status_code.start_with?("2")
  end

Jonathan Chang's avatar
Jonathan Chang committed
116
117
118
119
120
121
  sig do
    params(formula:         Formula,
           repo:            String,
           publish_package: T::Boolean,
           warn_on_error:   T::Boolean).returns(String)
  end
122
  def mirror_formula(formula, repo: "mirror", publish_package: false, warn_on_error: false)
123
124
125
126
127
128
129
130
131
132
133
134
135
136
    package = Utils::Bottles::Bintray.package formula.name

    create_package(repo: repo, package: package) unless package_exists?(repo: repo, package: package)

    formula.downloader.fetch

    version = ERB::Util.url_encode(formula.pkg_version)
    filename = ERB::Util.url_encode(formula.downloader.basename)
    destination_url = "https://dl.bintray.com/#{@bintray_org}/#{repo}/#{filename}"

    odebug "Uploading to #{destination_url}"

    upload(
      formula.downloader.cached_location,
137
138
139
140
141
142
      repo:          repo,
      package:       package,
      version:       version,
      sha256:        formula.stable.checksum,
      remote_file:   filename,
      warn_on_error: warn_on_error,
143
    )
144
145
146
    return destination_url unless publish_package

    odebug "Publishing #{@bintray_org}/#{repo}/#{package}/#{version}"
147
    publish(repo: repo, package: package, version: version, file_count: 1, warn_on_error: warn_on_error)
148
149
150
151

    destination_url
  end

Jonathan Chang's avatar
Jonathan Chang committed
152
153
  sig { params(repo: String, package: String).void }
  def create_package(repo:, package:)
154
    url = "#{API_URL}/packages/#{@bintray_org}/#{repo}"
155
156
    data = { name: package, public_download_numbers: true }
    data[:public_stats] = official_org?
Jonathan Chang's avatar
Jonathan Chang committed
157
    open_api(url, "--header", "Content-Type: application/json", "--request", "POST", "--data", data.to_json)
158
159
  end

Jonathan Chang's avatar
Jonathan Chang committed
160
  sig { params(repo: String, package: String).returns(T::Boolean) }
161
162
  def package_exists?(repo:, package:)
    url = "#{API_URL}/packages/#{@bintray_org}/#{repo}/#{package}"
163
    begin
Jonathan Chang's avatar
Jonathan Chang committed
164
      open_api(url, "--fail", "--silent", "--output", "/dev/null", auth: false)
165
    rescue ErrorDuringExecution => e
Alexander Bayandin's avatar
Alexander Bayandin committed
166
167
      stderr = e.output
                .select { |type,| type == :stderr }
168
169
170
171
172
173
174
175
                .map { |_, line| line }
                .join
      raise if e.status.exitstatus != 22 && !stderr.include?("404 Not Found")

      false
    else
      true
    end
176
177
  end

178
  # Gets the SHA-256 checksum of the specified remote file.
EricFromCanada's avatar
EricFromCanada committed
179
  #
Jonathan Chang's avatar
Jonathan Chang committed
180
181
  # @return the checksum, the empty string (if the file doesn't have a checksum), nil (if the file doesn't exist)
  sig { params(repo: String, remote_file: String).returns(T.nilable(String)) }
182
  def remote_checksum(repo:, remote_file:)
183
    url = "https://dl.bintray.com/#{@bintray_org}/#{repo}/#{remote_file}"
184
185
186
    result = curl_output "--fail", "--silent", "--head", url
    if result.success?
      result.stdout.match(/^X-Checksum-Sha2:\s+(\h{64})\b/i)&.values_at(1)&.first || ""
187
    else
188
189
190
      raise Error if result.status.exitstatus != 22 && !result.stderr.include?("404 Not Found")

      nil
191
192
193
    end
  end

Jonathan Chang's avatar
Jonathan Chang committed
194
  sig { params(bintray_repo: String, bintray_package: String, filename: String).returns(String) }
195
196
197
198
199
200
201
202
203
204
  def file_delete_instructions(bintray_repo, bintray_package, filename)
    <<~EOS
      Remove this file manually in your web browser:
        https://bintray.com/#{@bintray_org}/#{bintray_repo}/#{bintray_package}/view#files
      Or run:
        curl -X DELETE -u $HOMEBREW_BINTRAY_USER:$HOMEBREW_BINTRAY_KEY \\
        https://api.bintray.com/content/#{@bintray_org}/#{bintray_repo}/#{filename}
    EOS
  end

Jonathan Chang's avatar
Jonathan Chang committed
205
206
207
208
209
  sig do
    params(bottles_hash:    T::Hash[String, T.untyped],
           publish_package: T::Boolean,
           warn_on_error:   T.nilable(T::Boolean)).void
  end
210
  def upload_bottles(bottles_hash, publish_package: false, warn_on_error: false)
211
212
213
    formula_packaged = {}

    bottles_hash.each do |formula_name, bottle_hash|
Bo Anderson's avatar
Bo Anderson committed
214
      version = ERB::Util.url_encode(bottle_hash["formula"]["pkg_version"])
215
216
      bintray_package = bottle_hash["bintray"]["package"]
      bintray_repo = bottle_hash["bintray"]["repository"]
217
      bottle_count = bottle_hash["bottle"]["tags"].length
218
219

      bottle_hash["bottle"]["tags"].each do |_tag, tag_hash|
Bo Anderson's avatar
Bo Anderson committed
220
        filename = tag_hash["filename"] # URL encoded in Bottle::Filename#bintray
221
        sha256 = tag_hash["sha256"]
222
        delete_instructions = file_delete_instructions(bintray_repo, bintray_package, filename)
223

224
        odebug "Checking remote file #{@bintray_org}/#{bintray_repo}/#{filename}"
225
226
227
228
229
230
231
232
233
234
235
236
237
        result = remote_checksum(repo: bintray_repo, remote_file: filename)

        case result
        when nil
          # File doesn't exist.
          if !formula_packaged[formula_name] && !package_exists?(repo: bintray_repo, package: bintray_package)
            odebug "Creating package #{@bintray_org}/#{bintray_repo}/#{bintray_package}"
            create_package repo: bintray_repo, package: bintray_package
            formula_packaged[formula_name] = true
          end

          odebug "Uploading #{@bintray_org}/#{bintray_repo}/#{bintray_package}/#{version}/#{filename}"
          upload(tag_hash["local_filename"],
238
239
240
241
242
243
                 repo:          bintray_repo,
                 package:       bintray_package,
                 version:       version,
                 remote_file:   filename,
                 sha256:        sha256,
                 warn_on_error: warn_on_error)
244
245
246
247
248
249
250
251
252
253
254
255
        when sha256
          # File exists, checksum matches.
          odebug "#{filename} is already published with matching hash."
          bottle_count -= 1
        when ""
          # File exists, but can't find checksum
          failed_message = "#{filename} is already published!"
          raise Error, "#{failed_message}\n#{delete_instructions}" unless warn_on_error

          opoo failed_message
        else
          # File exists, but checksum either doesn't exist or is mismatched.
256
          failed_message = <<~EOS
257
258
259
            #{filename} is already published with a mismatched hash!
              Expected: #{sha256}
              Actual:   #{result}
260
          EOS
261
          raise Error, "#{failed_message}#{delete_instructions}" unless warn_on_error
262

263
          opoo failed_message
264
265
        end
      end
266
267
268
      next unless publish_package

      odebug "Publishing #{@bintray_org}/#{bintray_repo}/#{bintray_package}/#{version}"
269
270
271
272
273
      publish(repo:          bintray_repo,
              package:       bintray_package,
              version:       version,
              file_count:    bottle_count,
              warn_on_error: warn_on_error)
274
275
276
    end
  end
end