Clone of https://github.com/NixOS/nixpkgs.git (to stress-test knotserver)
at devShellTools-shell 642 lines 16 kB view raw
1#!/usr/bin/env nix-shell 2#!nix-shell -i ruby -p "ruby.withPackages (ps: with ps; [ curb nokogiri ])" nix-prefetch-git 3 4require 'set' 5require 'json' 6require 'uri' 7require 'shellwords' 8require 'erb' 9require 'rubygems' 10require 'curb' 11require 'nokogiri' 12 13# Performs a GET to an arbitrary address. 14# +url+:: the URL 15def get url, &block 16 curl = Curl::Easy.new(url) do |http| 17 http.follow_location = false 18 http.headers['User-Agent'] = 'nixpkgs dwarf fortress update bot' 19 yield http if block_given? 20 end 21 22 curl.perform 23 curl.body_str 24end 25 26# Performs a GET on the Github API. 27# +url+:: the relative URL to api.github.com 28def get_gh url, &block 29 ret = get URI.join('https://api.github.com/', url) do |http| 30 http.headers['Accept'] = 'application/vnd.github+json' 31 http.headers['Authorization'] = "Bearer #{ENV['GH_TOKEN']}" if ENV.include?('GH_TOKEN') 32 http.headers['X-GitHub-Api-Version'] = '2022-11-28' 33 yield http if block_given? 34 end 35 JSON.parse(ret, symbolize_names: true) 36end 37 38def normalize_keys hash 39 Hash[hash.map { 40 [ 41 _1.to_s, 42 _2.is_a?(Hash) ? normalize_keys(_2) : _2 43 ] 44 }] 45end 46 47module Mergeable 48 # Merges this Mergeable with something else. 49 # +other+:: The other Mergeable. 50 def merge other 51 if !other 52 return self 53 end 54 55 if !other.is_a?(Mergeable) || self.members != other.members 56 raise "invalid right-hand operand for merge: #{other.members}" 57 end 58 59 hash = {} 60 self.members.each do |member| 61 if @@expensive && @@expensive.include?(member) 62 # Already computed 63 hash[member] = other[member] || self.send(member) 64 elsif self.send(member) && self.send(member).is_a?(Mergeable) 65 # Merge it 66 hash[member] = self.send(member).merge(other.send(member)) 67 elsif self.send(member) && self.send(member).is_a?(Hash) 68 hash[member] = Hash[other.send(member).map { 69 [_1, self.send(member)[_1] && self.send(member)[_1].is_a?(Mergeable) ? self.send(member)[_1].merge(_2) : _2] 70 }] 71 else 72 # Compute it 73 hash[member] = other.send(member) 74 end 75 end 76 self.class.new(**hash) 77 end 78 79 # Marks some attributes as expensive. 80 def expensive *attrs 81 @@expensive ||= Set.new 82 attrs.each {@@expensive << _1} 83 self 84 end 85 86 # Materializes this object. 87 def materialize! 88 self.members.each do |name| 89 member = self.send(name) 90 if member.respond_to?(:materialize!) 91 member.materialize! 92 end 93 self[name] = member 94 end 95 self 96 end 97end 98 99module Versionable 100 # Parses the version. 101 def parsed_version 102 @version ||= Gem::Version.create(self.version.partition('-').first) 103 end 104 105 # Drops the last component of the version for chunking. 106 def major_version 107 @major_version ||= Gem::Version.create(self.parsed_version.canonical_segments[..-2].join('.')) 108 end 109 110 # Compares the major version. 111 def =~ other 112 self.major_version == other.major_version 113 end 114 115 # Negation of the above. 116 def !~ other 117 !(self =~ other) 118 end 119 120 # Compares two versions. 121 def <=> other 122 other.parsed_version <=> self.parsed_version 123 end 124end 125 126class DFUrl < Struct.new(:url, :output_hash, keyword_init: true) 127 include Mergeable 128 extend Mergeable 129 130 expensive :output_hash 131 132 # Converts this DFUrl to a hash. 133 def to_h 134 { 135 url: self.url, 136 outputHash: self.output_hash 137 } 138 end 139 140 # Returns or computes the output hash. 141 def output_hash 142 return super if super 143 self.output_hash = `nix-prefetch-url #{Shellwords.escape(self.url.to_s)} | xargs nix-hash --to-sri --type sha256`.strip 144 super 145 end 146 147 # Converts this DFUrl from a hash. 148 # +hash+:: The hash 149 def self.from_hash hash 150 DFUrl.new( 151 url: hash.fetch(:url), 152 output_hash: hash[:outputHash] 153 ) 154 end 155end 156 157class DFGithub < Struct.new(:url, :revision, :output_hash, keyword_init: true) 158 include Mergeable 159 extend Mergeable 160 161 expensive :output_hash 162 163 # Converts this DFGithub to a hash. 164 def to_h 165 { 166 url: self.url, 167 revision: self.revision, 168 outputHash: self.output_hash 169 } 170 end 171 172 # Returns or computes the output hash. 173 def output_hash 174 return super if super 175 url = URI.parse(self.url.to_s) 176 if ENV['GH_TOKEN'] 177 url.userinfo = ENV['GH_TOKEN'] 178 end 179 self.output_hash = JSON.parse(`nix-prefetch-git --no-deepClone --fetch-submodules #{Shellwords.escape(url.to_s)} #{Shellwords.escape(self.revision.to_s)}`, symbolize_names: true).fetch(:hash) 180 super 181 end 182 183 # Converts a hash to a DFGithub. 184 # +hash+:: The hash 185 def self.from_hash hash 186 DFGithub.new( 187 url: hash.fetch(:url), 188 revision: hash.fetch(:revision), 189 output_hash: hash[:outputHash] 190 ) 191 end 192end 193 194class DFVersion < Struct.new(:version, :urls, keyword_init: true) 195 include Mergeable 196 extend Mergeable 197 include Versionable 198 199 # Converts a DFVersion to a hash. 200 def to_h 201 { 202 version: self.version, 203 urls: Hash[self.urls.map { 204 [_1, _2.to_h] 205 }] 206 } 207 end 208 209 # Converts a hash to a DFVersion. 210 # +hash+:: The hash 211 def self.from_hash hash 212 DFVersion.new( 213 version: hash.fetch(:version), 214 urls: Hash[hash.fetch(:urls).map { 215 [_1, DFUrl.from_hash(_2)] 216 }] 217 ) 218 end 219 220 # Converts an HTML node to a DFVersion. 221 # +base+:: The base URL for DF downloads. 222 # +node+:: The HTML node 223 def self.from_node base, node 224 match = node.text.match(/DF\s+(\d+\.\d+(?:\.\d+)?)/) 225 if match 226 systems = {} 227 node.css('a').each do |a| 228 case a['href'] 229 when /osx\.tar/ then systems[:darwin] = DFUrl.new(url: URI.join(base, a['href']).to_s) 230 when /linux\.tar/ then systems[:linux] = DFUrl.new(url: URI.join(base, a['href']).to_s) 231 end 232 end 233 234 if systems.empty? 235 nil 236 else 237 DFVersion.new(version: match[1], urls: systems) 238 end 239 else 240 nil 241 end 242 end 243 244 # Returns all DFVersions from the download page. 245 # +cutoff+:: The minimum version 246 def self.all cutoff: 247 cutoff = Gem::Version.create(cutoff) 248 249 base = 'https://www.bay12games.com/dwarves/' 250 res = get URI.join(base, 'older_versions.html') 251 parsed = Nokogiri::HTML(res) 252 253 # Figure out which versions we care about. 254 parsed.css('p.menu').map {DFVersion.from_node(base, _1)}.select { 255 _1 && _1.parsed_version >= cutoff 256 }.sort.chunk { 257 _1.major_version 258 }.map {|*, versions| 259 versions.max_by {_1.parsed_version} 260 }.to_a 261 end 262end 263 264class DFHackVersion < Struct.new(:version, :git, :xml_rev, keyword_init: true) 265 include Mergeable 266 extend Mergeable 267 include Versionable 268 269 expensive :xml_rev 270 271 # Returns the download URL. 272 def git 273 return super if super 274 self.git = DFGithub.new( 275 url: "https://github.com/DFHack/dfhack.git", 276 revision: self.version 277 ) 278 super 279 end 280 281 # Converts this DFHackVersion to a hash. 282 def to_h 283 { 284 version: self.version, 285 git: self.git.to_h, 286 xmlRev: self.xml_rev, 287 } 288 end 289 290 # Returns the revision number in the version. Defaults to 0. 291 def rev 292 return @rev if @rev 293 rev = self.version.match(/-r([\d\.]+)\z/) 294 @rev = rev[1].to_f if rev 295 @rev ||= 0 296 @rev 297 end 298 299 # Returns the XML revision, fetching it if necessary. 300 def xml_rev 301 return super if super 302 url = "repos/dfhack/dfhack/contents/library/xml?ref=#{URI.encode_uri_component(self.git.revision)}" 303 body = get_gh url 304 self.xml_rev = body.fetch(:sha) 305 super 306 end 307 308 # Compares two DFHack versions. 309 # +other+:: the other dfhack version 310 def <=> other 311 ret = super 312 ret = other.rev <=> self.rev if ret == 0 313 ret 314 end 315 316 # Returns a version from a hash. 317 # +hash+:: the hash 318 def self.from_hash hash 319 DFHackVersion.new( 320 version: hash.fetch(:version), 321 git: DFGithub.from_hash(hash.fetch(:git)), 322 xml_rev: hash[:xmlRev] 323 ) 324 end 325 326 # Returns a release from a github object. 327 # +github_obj+:: The github object. Returns null for prereleases. 328 def self.from_github github_obj 329 if github_obj.fetch(:prerelease) 330 return nil 331 end 332 version = github_obj.fetch(:tag_name) 333 DFHackVersion.new(version: version) 334 end 335 336 # Returns all dfhack versions. 337 # +cutoff+:: The cutoff version. 338 def self.all cutoff: 339 cutoff = Gem::Version.create(cutoff) 340 ret = {} 341 (1..).each do |page| 342 url = "repos/dfhack/dfhack/releases?per_page=100&page=#{page}" 343 releases = get_gh url 344 345 releases.each do |release| 346 release = DFHackVersion.from_github(release) 347 if release && release.parsed_version >= cutoff 348 ret[release.major_version] ||= {} 349 ret[release.major_version][release.parsed_version] ||= [] 350 ret[release.major_version][release.parsed_version] << release 351 end 352 end 353 354 break if releases.length < 1 355 end 356 357 ret.each do |_, dfhack_major_versions| 358 dfhack_major_versions.each do |_, dfhack_minor_versions| 359 dfhack_minor_versions.sort! 360 end 361 end 362 363 ret 364 end 365end 366 367class DFWithHackVersion < Struct.new(:df, :hack, keyword_init: true) 368 include Mergeable 369 extend Mergeable 370 371 # Converts this DFWithHackVersion to a hash. 372 def to_h 373 { 374 df: self.df.to_h, 375 hack: self.hack.to_h 376 } 377 end 378 379 # Converts a hash to a DFWithHackVersion. 380 # +hash+:: the hash to convert 381 def self.from_hash hash 382 DFWithHackVersion.new( 383 df: DFVersion.from_hash(hash.fetch(:df)), 384 hack: DFHackVersion.from_hash(hash.fetch(:hack)) 385 ) 386 end 387end 388 389class DFWithHackVersions < Struct.new(:latest, :versions, keyword_init: true) 390 include Mergeable 391 extend Mergeable 392 393 # Initializes this DFWithHackVersions. 394 def initialize *args, **kw 395 super *args, **kw 396 self.latest ||= {} 397 self.versions ||= {} 398 end 399 400 # Converts this DFWithHackVersions to a hash. 401 def to_h 402 { 403 latest: self.latest, 404 versions: Hash[self.versions.map { 405 [_1.to_s, _2.to_h] 406 }] 407 } 408 end 409 410 # Loads this DFWithHackVersions. 411 # +cutoff+:: The minimum version to load. 412 def load! cutoff: 413 df_versions = DFVersion.all(cutoff: cutoff) 414 dfhack_versions = DFHackVersion.all(cutoff: cutoff) 415 416 df_versions.each do |df_version| 417 latest_dfhack_version = nil 418 corresponding_dfhack_versions = dfhack_versions.dig(df_version.major_version, df_version.parsed_version) 419 if corresponding_dfhack_versions 420 latest_dfhack_version = corresponding_dfhack_versions.first 421 end 422 423 if latest_dfhack_version 424 df_version.urls.each do |platform, url| 425 if !self.latest[platform] || df_version.parsed_version > Gem::Version.create(self.latest[platform]) 426 self.latest[platform] = df_version.version 427 end 428 end 429 self.versions[df_version.version] = DFWithHackVersion.new(df: df_version, hack: latest_dfhack_version) 430 end 431 end 432 433 self.materialize! 434 self 435 end 436 437 # Converts a hash to a DFWithHackVersions. 438 # +hash+:: The hash 439 def self.from_hash hash 440 DFWithHackVersions.new( 441 latest: hash.fetch(:latest), 442 versions: Hash[hash.fetch(:versions).map { 443 [_1.to_s, DFWithHackVersion.from_hash(_2)] 444 }] 445 ) 446 end 447end 448 449class Therapist < Struct.new(:version, :max_df_version, :git, keyword_init: true) 450 include Mergeable 451 extend Mergeable 452 include Versionable 453 454 expensive :max_df_version 455 456 # Converts this Therapist instance to a hash. 457 def to_h 458 { 459 version: self.version, 460 maxDfVersion: self.max_df_version, 461 git: self.git.to_h 462 } 463 end 464 465 # Returns the max supported DF version. 466 def max_df_version 467 return super if super 468 url = "repos/Dwarf-Therapist/Dwarf-Therapist/contents/share/memory_layouts/linux?ref=#{URI.encode_uri_component(self.git.revision)}" 469 body = get_gh url 470 471 # Figure out the max supported memory layout. 472 max_version = nil 473 max_version_str = nil 474 body.each do |item| 475 name = item[:name] || "" 476 match = name.match(/\Av(?:0\.)?(\d+\.\d+)-classic_linux\d*\.ini/) 477 if match 478 version = Gem::Version.create(match[1]) 479 if !max_version || version > max_version 480 max_version = version 481 max_version_str = match[1] 482 end 483 end 484 end 485 486 self.max_df_version = max_version_str 487 488 super 489 end 490 491 # Returns a Github URL. 492 def git 493 return super if super 494 self.git = DFGithub.new( 495 url: "https://github.com/Dwarf-Therapist/Dwarf-Therapist.git", 496 revision: 'v' + self.version 497 ) 498 super 499 end 500 501 # Loads this therapist instance from Github. 502 def load! 503 latest = self.class.latest 504 self.version = latest.version 505 self.max_df_version = latest.max_df_version 506 self.git = latest.git 507 self.materialize! 508 self 509 end 510 511 # Loads a hash into this Therapist instance. 512 # +hash+: the hash 513 def self.from_hash hash 514 Therapist.new( 515 version: hash.fetch(:version), 516 max_df_version: hash[:maxDfVersion], 517 git: DFGithub.from_hash(hash.fetch(:git)) 518 ) 519 end 520 521 # Returns a release from a github object. 522 # +github_obj+:: The github object. Returns null for prereleases. 523 def self.from_github github_obj 524 if github_obj.fetch(:prerelease) 525 return nil 526 end 527 528 version = github_obj.fetch(:tag_name) 529 match = version.match(/\Av([\d\.]+)\z/) 530 if match 531 Therapist.new(version: match[1]) 532 else 533 nil 534 end 535 end 536 537 # Returns the latest Therapist version. 538 def self.latest 539 url = "repos/Dwarf-Therapist/Dwarf-Therapist/releases" 540 releases = get_gh url 541 542 releases.each do |release| 543 release = Therapist.from_github(release) 544 if release 545 return release 546 end 547 end 548 549 nil 550 end 551end 552 553class DFLock < Struct.new(:game, :therapist, keyword_init: true) 554 include Mergeable 555 extend Mergeable 556 557 # Initializes this DFLock. 558 def initialize *args, **kw 559 super *args, **kw 560 self.game ||= DFWithHackVersions.new 561 self.therapist ||= Therapist.new 562 end 563 564 # Converts this DFLock to a hash. 565 def to_h 566 { 567 game: self.game.to_h, 568 therapist: self.therapist.to_h 569 } 570 end 571 572 # Returns an array containing all versions. 573 def all_versions 574 self.game.versions.keys.map {"DF #{_1}"}.to_a + ["DT #{self.therapist.version}"] 575 end 576 577 # Loads this DFLock. 578 # +cutoff+:: The minimum DF version to load. 579 def load! cutoff: 580 self.game.load! cutoff: cutoff 581 self.therapist.load! 582 end 583 584 # Converts a hash to a DFLock. 585 # +hash+:: The hash 586 def self.from_hash hash 587 DFLock.new( 588 game: DFWithHackVersions.from_hash(hash.fetch(:game)), 589 therapist: Therapist.from_hash(hash.fetch(:therapist)) 590 ) 591 end 592end 593 594# 0.43 and below has a broken dfhack. 595new_df_lock = DFLock.new 596new_df_lock.load! cutoff: '0.44' 597 598df_lock_file = File.join(__dir__, 'df.lock.json') 599df_lock, df_lock_json = if File.file?(df_lock_file) 600 json = JSON.parse(File.read(df_lock_file), symbolize_names: true) 601 [DFLock.from_hash(json), json] 602 else 603 [DFLock.new, {}] 604 end 605 606new_df_lock_json = df_lock.merge(new_df_lock).to_h 607json = JSON.pretty_generate(new_df_lock_json) 608json << "\n" 609STDERR.puts json 610File.write(df_lock_file, json) 611 612# See if there were any changes. 613changed_paths = [] 614if normalize_keys(df_lock_json) != normalize_keys(new_df_lock_json) 615 all_old_versions = df_lock.all_versions 616 all_new_versions = new_df_lock.all_versions 617 just_old_versions = all_old_versions - all_new_versions 618 just_new_versions = all_new_versions - all_old_versions 619 changes = just_old_versions.zip(just_new_versions) 620 621 template = ERB.new(<<-EOF, trim_mode: '<>-') 622dwarf-fortress-packages: <%= changes.map {|old, new| '%s -> %s' % [old, new]}.join('; ') %> 623 624Performed the following automatic DF updates: 625 626<% changes.each do |old, new| %> 627- <%= old -%> -> <%= new -%> 628<% end %> 629EOF 630 631 changed_paths << { 632 attrPath: 'dwarf-fortress-packages', 633 oldVersion: just_old_versions.join('; '), 634 newVersion: just_new_versions.join('; '), 635 files: [ 636 File.realpath(df_lock_file) 637 ], 638 commitMessage: template.result(binding) 639 } 640end 641 642STDOUT.puts JSON.pretty_generate(changed_paths)