nixpkgs mirror (for testing)
github.com/NixOS/nixpkgs
nix
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