nixpkgs mirror (for testing) github.com/NixOS/nixpkgs
nix
at python-updates 632 lines 19 kB view raw
1#!/usr/bin/env nix-shell 2#!nix-shell -i ruby -p "ruby.withPackages (ps: with ps; [ slop curb nokogiri ])" 3 4require 'json' 5require 'digest' 6require 'rubygems' 7require 'shellwords' 8require 'erb' 9require 'uri' 10require 'stringio' 11require 'slop' 12require 'curb' 13require 'nokogiri' 14 15# Returns a repo URL for a given package name. 16def repo_url value 17 if value && value.start_with?('http') 18 value 19 elsif value 20 "https://dl.google.com/android/repository/#{value}" 21 else 22 nil 23 end 24end 25 26# Returns a system image URL for a given system image name. 27def image_url value, dir 28 if dir == "default" 29 dir = "android" 30 end 31 if value && value.start_with?('http') 32 value 33 elsif value 34 "https://dl.google.com/android/repository/sys-img/#{dir}/#{value}" 35 else 36 nil 37 end 38end 39 40# Runs a GET with curl. 41def _curl_get url 42 curl = Curl::Easy.new(url) do |http| 43 http.headers['User-Agent'] = 'nixpkgs androidenv update bot' 44 yield http if block_given? 45 end 46 STDERR.print "GET #{url}" 47 curl.perform 48 STDERR.puts "... #{curl.response_code}" 49 50 StringIO.new(curl.body_str) 51end 52 53# Retrieves a repo from the filesystem or a URL. 54def get location 55 uri = URI.parse(location) 56 case uri.scheme 57 when 'repo' 58 _curl_get repo_url("#{uri.host}#{uri.fragment}.xml") 59 when 'image' 60 _curl_get image_url("sys-img#{uri.fragment}.xml", uri.host) 61 else 62 if File.exist?(uri.path) 63 File.open(uri.path, 'rt') 64 else 65 raise "Repository #{uri} was neither a file nor a repo URL" 66 end 67 end 68end 69 70# Returns a JSON with the data and structure of the input XML 71def to_json_collector doc 72 json = {} 73 index = 0 74 doc.element_children.each { |node| 75 if node.children.length == 1 and node.children.first.text? 76 json["#{node.name}:#{index}"] ||= node.content 77 index += 1 78 next 79 end 80 json["#{node.name}:#{index}"] ||= to_json_collector node 81 index += 1 82 } 83 element_attributes = {} 84 doc.attribute_nodes.each do |attr| 85 if attr.name == "type" 86 type = attr.value.split(':', 2).last 87 case attr.value 88 when 'generic:genericDetailsType' 89 element_attributes["xsi:type"] ||= "ns5:#{type}" 90 when 'addon:extraDetailsType' 91 element_attributes["xsi:type"] ||= "ns8:#{type}" 92 when 'addon:mavenType' 93 element_attributes["xsi:type"] ||= "ns8:#{type}" 94 when 'sdk:platformDetailsType' 95 element_attributes["xsi:type"] ||= "ns11:#{type}" 96 when 'sdk:sourceDetailsType' 97 element_attributes["xsi:type"] ||= "ns11:#{type}" 98 when 'sys-img:sysImgDetailsType' 99 element_attributes["xsi:type"] ||= "ns12:#{type}" 100 when 'addon:addonDetailsType' then 101 element_attributes["xsi:type"] ||= "ns8:#{type}" 102 end 103 else 104 element_attributes[attr.name] ||= attr.value 105 end 106 end 107 if !element_attributes.empty? 108 json['element-attributes'] ||= element_attributes 109 end 110 json 111end 112 113# Returns a tuple of [type, revision, revision components] for a package node. 114def package_revision package 115 type_details = package.at_css('> type-details') 116 type = type_details.attributes['type'] 117 type &&= type.value 118 119 revision = nil 120 components = nil 121 122 case type 123 when 'generic:genericDetailsType', 'addon:extraDetailsType', 'addon:mavenType' 124 major = text package.at_css('> revision > major') 125 minor = text package.at_css('> revision > minor') 126 micro = text package.at_css('> revision > micro') 127 preview = text package.at_css('> revision > preview') 128 129 revision = '' 130 components = [] 131 unless empty?(major) 132 revision << major 133 components << major 134 end 135 136 unless empty?(minor) 137 revision << ".#{minor}" 138 components << minor 139 end 140 141 unless empty?(micro) 142 revision << ".#{micro}" 143 components << micro 144 end 145 146 unless empty?(preview) 147 revision << "-rc#{preview}" 148 components << preview 149 end 150 when 'sdk:platformDetailsType' 151 codename = text type_details.at_css('> codename') 152 api_level = text type_details.at_css('> api-level') 153 revision = empty?(codename) ? api_level : codename 154 components = [revision] 155 when 'sdk:sourceDetailsType' 156 api_level = text type_details.at_css('> api-level') 157 revision, components = api_level, [api_level] 158 when 'sys-img:sysImgDetailsType' 159 codename = text type_details.at_css('> codename') 160 api_level = text type_details.at_css('> api-level') 161 id = text type_details.at_css('> tag > id') 162 abi = text type_details.at_css('> abi') 163 164 revision = '' 165 components = [] 166 if empty?(codename) 167 revision << api_level 168 components << api_level 169 else 170 revision << codename 171 components << codename 172 end 173 174 unless empty?(id) 175 revision << "-#{id}" 176 components << id 177 end 178 179 unless empty?(abi) 180 revision << "-#{abi}" 181 components << abi 182 end 183 when 'addon:addonDetailsType' then 184 api_level = text type_details.at_css('> api-level') 185 id = text type_details.at_css('> tag > id') 186 revision = api_level 187 components = [api_level, id] 188 end 189 190 [type, revision, components] 191end 192 193# Returns a hash of archives for the specified package node. 194def package_archives package 195 archives = {} 196 package.css('> archives > archive').each do |archive| 197 host_os = text archive.at_css('> host-os') 198 host_arch = text archive.at_css('> host-arch') 199 host_os = 'all' if empty?(host_os) 200 host_arch = 'all' if empty?(host_arch) 201 archives[host_os + host_arch] = { 202 'os' => host_os, 203 'arch' => host_arch, 204 'size' => Integer(text(archive.at_css('> complete > size'))), 205 'sha1' => text(archive.at_css('> complete > checksum')), 206 'url' => yield(text(archive.at_css('> complete > url'))) 207 } 208 end 209 archives 210end 211 212# Returns the text from a node, or nil. 213def text node 214 node ? node.text : nil 215end 216 217# Nil or empty helper. 218def empty? value 219 !value || value.empty? 220end 221 222# Fixes up returned hashes by converting archives like 223# (e.g. {'linux' => {'sha1' => ...}, 'macosx' => ...} to 224# [{'os' => 'linux', 'sha1' => ...}, {'os' => 'macosx', ...}, ...]. 225def fixup value 226 Hash[value.map do |k, v| 227 if k == 'archives' && v.is_a?(Hash) 228 [k, v.map do |os, archive| 229 fixup(archive) 230 end] 231 elsif v.is_a?(Hash) 232 [k, fixup(v)] 233 else 234 [k, v] 235 end 236 end] 237end 238 239# Today since Unix Epoch, January 1, 1970. 240def today 241 Time.now.utc.to_i / 24 / 60 / 60 242end 243 244# The expiration strategy. Expire if the last available day was before the `oldest_valid_day`. 245def expire_records record, oldest_valid_day 246 if record.is_a?(Hash) 247 if record.has_key?('last-available-day') && 248 record['last-available-day'] < oldest_valid_day 249 return nil 250 end 251 update = {} 252 # This should only happen in the first run of this scrip after adding the `expire_record` function. 253 if record.has_key?('displayName') && 254 !record.has_key?('last-available-day') 255 update['last-available-day'] = today 256 end 257 record.each {|key, value| 258 v = expire_records value, oldest_valid_day 259 update[key] = v if v 260 } 261 update 262 else 263 record 264 end 265end 266 267# Normalize the specified license text. 268# See: https://brash-snapper.glitch.me/ for how the munging works. 269def normalize_license license 270 license = license.dup 271 license.gsub!(/([^\n])\n([^\n])/m, '\1 \2') 272 license.gsub!(/ +/, ' ') 273 license.strip! 274 license 275end 276 277# Gets all license texts, deduplicating them. 278def get_licenses doc 279 licenses = {} 280 doc.css('license[type="text"]').each do |license_node| 281 license_id = license_node['id'] 282 if license_id 283 licenses[license_id] ||= [] 284 licenses[license_id] |= [normalize_license(text(license_node))] 285 end 286 end 287 licenses 288end 289 290def parse_package_xml doc 291 licenses = get_licenses doc 292 packages = {} 293 # check https://github.com/NixOS/nixpkgs/issues/373785 294 extras = {} 295 296 doc.css('remotePackage').each do |package| 297 name, _, version = package['path'].partition(';') 298 next if version == 'latest' 299 300 is_extras = name == 'extras' 301 if is_extras 302 name = package['path'].tr(';', '-') 303 end 304 305 type, revision, _ = package_revision(package) 306 next unless revision 307 308 path = package['path'].tr(';', '/') 309 display_name = text package.at_css('> display-name') 310 uses_license = package.at_css('> uses-license') 311 uses_license &&= uses_license['ref'] 312 obsolete ||= package['obsolete'] 313 type_details = to_json_collector package.at_css('> type-details') 314 revision_details = to_json_collector package.at_css('> revision') 315 archives = package_archives(package) {|url| repo_url url} 316 dependencies_xml = package.at_css('> dependencies') 317 dependencies = to_json_collector dependencies_xml if dependencies_xml 318 319 if is_extras 320 target = extras 321 component = package['path'] 322 target = (target[component] ||= {}) 323 else 324 target = (packages[name] ||= {}) 325 target = (target[revision] ||= {}) 326 end 327 328 target['name'] ||= name 329 target['path'] ||= path 330 target['revision'] ||= revision 331 target['displayName'] ||= display_name 332 target['license'] ||= uses_license if uses_license 333 target['obsolete'] ||= obsolete if obsolete == 'true' 334 target['type-details'] ||= type_details 335 target['revision-details'] ||= revision_details 336 target['dependencies'] ||= dependencies if dependencies 337 target['archives'] ||= {} 338 merge target['archives'], archives 339 target['last-available-day'] = today 340 end 341 342 [licenses, packages, extras] 343end 344 345def parse_image_xml doc 346 licenses = get_licenses doc 347 images = {} 348 349 doc.css('remotePackage[path^="system-images;"]').each do |package| 350 type, revision, components = package_revision(package) 351 next unless revision 352 353 path = package['path'].tr(';', '/') 354 display_name = text package.at_css('> display-name') 355 uses_license = package.at_css('> uses-license') 356 uses_license &&= uses_license['ref'] 357 obsolete &&= package['obsolete'] 358 type_details = to_json_collector package.at_css('> type-details') 359 revision_details = to_json_collector package.at_css('> revision') 360 archives = package_archives(package) {|url| image_url url, components[-2]} 361 dependencies_xml = package.at_css('> dependencies') 362 dependencies = to_json_collector dependencies_xml if dependencies_xml 363 364 target = images 365 components.each do |component| 366 target[component] ||= {} 367 target = target[component] 368 end 369 370 target['name'] ||= "system-image-#{revision}" 371 target['path'] ||= path 372 target['revision'] ||= revision 373 target['displayName'] ||= display_name 374 target['license'] ||= uses_license if uses_license 375 target['obsolete'] ||= obsolete if obsolete 376 target['type-details'] ||= type_details 377 target['revision-details'] ||= revision_details 378 target['dependencies'] ||= dependencies if dependencies 379 target['archives'] ||= {} 380 merge target['archives'], archives 381 target['last-available-day'] = today 382 end 383 384 [licenses, images] 385end 386 387def parse_addon_xml doc 388 licenses = get_licenses doc 389 addons, extras = {}, {} 390 391 doc.css('remotePackage').each do |package| 392 type, revision, components = package_revision(package) 393 next unless revision 394 395 path = package['path'].tr(';', '/') 396 display_name = text package.at_css('> display-name') 397 uses_license = package.at_css('> uses-license') 398 uses_license &&= uses_license['ref'] 399 obsolete &&= package['obsolete'] 400 type_details = to_json_collector package.at_css('> type-details') 401 revision_details = to_json_collector package.at_css('> revision') 402 archives = package_archives(package) {|url| repo_url url} 403 dependencies_xml = package.at_css('> dependencies') 404 dependencies = to_json_collector dependencies_xml if dependencies_xml 405 406 case type 407 when 'addon:addonDetailsType' 408 name = components.last 409 target = addons 410 411 # Hack for Google APIs 25 r1, which displays as 23 for some reason 412 archive_name = text package.at_css('> archives > archive > complete > url') 413 if archive_name == 'google_apis-25_r1.zip' 414 path = 'add-ons/addon-google_apis-google-25' 415 revision = '25' 416 components = [revision, components.last] 417 end 418 when 'addon:extraDetailsType', 'addon:mavenType' 419 name = package['path'].tr(';', '-') 420 components = [package['path']] 421 target = extras 422 end 423 424 components.each do |component| 425 target = (target[component] ||= {}) 426 end 427 428 target['name'] ||= name 429 target['path'] ||= path 430 target['revision'] ||= revision 431 target['displayName'] ||= display_name 432 target['license'] ||= uses_license if uses_license 433 target['obsolete'] ||= obsolete if obsolete 434 target['type-details'] ||= type_details 435 target['revision-details'] ||= revision_details 436 target['dependencies'] ||= dependencies if dependencies 437 target['archives'] ||= {} 438 merge target['archives'], archives 439 target['last-available-day'] = today 440 end 441 442 [licenses, addons, extras] 443end 444 445# Make the clean diff by always sorting the result before puting it in the stdout. 446def sort_recursively value 447 if value.is_a?(Hash) 448 Hash[ 449 value.map do |k, v| 450 [k, sort_recursively(v)] 451 end.sort_by {|(k, v)| k } 452 ] 453 elsif value.is_a?(Array) 454 value.map do |v| sort_recursively(v) end 455 else 456 value 457 end 458end 459 460def merge_recursively a, b 461 a.merge!(b) {|key, a_item, b_item| 462 if a_item.is_a?(Hash) && b_item.is_a?(Hash) 463 merge_recursively(a_item, b_item) 464 elsif b_item != nil 465 b_item 466 end 467 } 468 a 469end 470 471def merge dest, src 472 merge_recursively dest, src 473end 474 475opts = Slop.parse do |o| 476 o.array '-p', '--packages', 'packages repo XMLs to parse', default: %w[repo://repository#2-3] 477 o.array '-i', '--images', 'system image repo XMLs to parse', default: %w[ 478 image://android#2-3 479 image://android-tv#2-3 480 image://android-wear#2-3 481 image://android-wear-cn#2-3 482 image://android-automotive#2-3 483 image://google_apis#2-3 484 image://google_apis_playstore#2-3 485 ] 486 o.array '-a', '--addons', 'addon repo XMLs to parse', default: %w[repo://addon#2-3] 487 o.string '-I', '--input', 'input JSON file for repo', default: File.join(__dir__, 'repo.json') 488 o.string '-O', '--output', 'output JSON file for repo', default: File.join(__dir__, 'repo.json') 489end 490 491result = {} 492result['licenses'] = {} 493result['packages'] = {} 494result['images'] = {} 495result['addons'] = {} 496result['extras'] = {} 497 498opts[:packages].each do |filename| 499 licenses, packages, extras = parse_package_xml(Nokogiri::XML(get(filename)) { |conf| conf.noblanks }) 500 merge result['licenses'], licenses 501 merge result['packages'], packages 502 merge result['extras'], extras 503end 504 505opts[:images].each do |filename| 506 licenses, images = parse_image_xml(Nokogiri::XML(get(filename)) { |conf| conf.noblanks }) 507 merge result['licenses'], licenses 508 merge result['images'], images 509end 510 511opts[:addons].each do |filename| 512 licenses, addons, extras = parse_addon_xml(Nokogiri::XML(get(filename)) { |conf| conf.noblanks }) 513 merge result['licenses'], licenses 514 merge result['addons'], addons 515 merge result['extras'], extras 516end 517 518result['latest'] = {} 519result['packages'].each do |name, versions| 520 max_version = Gem::Version.new('0') 521 versions.each do |version, package| 522 if package['license'] == 'android-sdk-license' && Gem::Version.correct?(package['revision']) 523 package_version = Gem::Version.new(package['revision']) 524 max_version = package_version if package_version > max_version 525 end 526 end 527 result['latest'][name] = max_version.to_s 528end 529 530# As we keep the old packages in the repo JSON file, we should have 531# a strategy to remove them at some point! 532# So with this variable we claim it's okay to remove them from the 533# JSON after two years that they are not available. 534two_years_ago = today - 2 * 365 535 536input = {} 537prev_latest = {} 538begin 539 input_json = if File.exist?(opts[:input]) 540 STDERR.puts "Reading #{opts[:input]}" 541 File.read(opts[:input]) 542 else 543 STDERR.puts "Creating new repo" 544 "{}" 545 end 546 547 if input_json != nil && !input_json.empty? 548 input = expire_records(JSON.parse(input_json), two_years_ago) 549 550 # Just create a new set of latest packages. 551 prev_latest = input['latest'] || {} 552 input['latest'] = {} 553 end 554rescue JSON::ParserError => e 555 STDERR.write(e.message) 556 return 557end 558 559fixup_result = fixup(result) 560 561# Regular installation of Android SDK would keep the previously installed packages even if they are not 562# in the uptodate XML files, so here we try to support this logic by keeping un-available packages, 563# therefore the old packages will work as long as the links are working on the Google servers. 564output = sort_recursively(merge(input, fixup_result)) 565 566# Fingerprint the latest versions. 567fingerprint = Digest::SHA256.hexdigest(output['latest'].tap {_1.delete 'fingerprint'}.to_json)[0...16] 568output['latest']['fingerprint'] = fingerprint 569 570# Write the repository. Append a \n to keep nixpkgs Github Actions happy. 571STDERR.puts "Writing #{opts[:output]}" 572File.write opts[:output], (JSON.pretty_generate(output) + "\n") 573 574# Output metadata for the nixpkgs update script. 575if ENV['UPDATE_NIX_ATTR_PATH'] 576 # See if there are any changes in the latest versions. 577 cur_latest = output['latest'] || {} 578 579 old_versions = [] 580 new_versions = [] 581 changes = [] 582 changed = false 583 584 cur_latest.each do |k, v| 585 prev = prev_latest[k] 586 if k != 'fingerprint' && prev && prev != v 587 old_versions << "#{k}:#{prev}" 588 new_versions << "#{k}:#{v}" 589 changes << "#{k}: #{prev} -> #{v}" 590 changed = true 591 end 592 end 593 594 changed_paths = [] 595 if changed 596 # Instantiate it. 597 test_result = `NIXPKGS_ALLOW_UNFREE=1 NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1 nix-build #{Shellwords.escape(File.realpath(File.join(__dir__, '..', '..', '..', '..', 'default.nix')))} -A #{Shellwords.join [ENV['UPDATE_NIX_ATTR_PATH']]} 2>&1` 598 test_status = $?.exitstatus 599 600 template = ERB.new(<<-EOF, trim_mode: '<>-') 601androidenv: <%= changes.join('; ') %> 602 603Performed the following automatic androidenv updates: 604 605<% changes.each do |change| %> 606- <%= change -%> 607<% end %> 608 609Tests exited with status: <%= test_status -%> 610 611<% if !test_result.empty? %> 612Last 100 lines of output: 613``` 614<%= test_result.lines.last(100).join -%> 615``` 616<% end %> 617EOF 618 619 changed_paths << { 620 attrPath: 'androidenv.androidPkgs.androidsdk', 621 oldVersion: old_versions.join('; '), 622 newVersion: new_versions.join('; '), 623 files: [ 624 opts[:output] 625 ], 626 commitMessage: template.result(binding) 627 } 628 end 629 630 # nix-update info is on stdout 631 STDOUT.puts JSON.pretty_generate(changed_paths) 632end