Skip to content

Commit 008d261

Browse files
committed
Add update_gems rake task
This rake task will inspect the data in each rubygems-*.rb file and use the RubyGems API to find if there are any new versions available, and update accordingly. It will also add missing or new dependencies, and if those are not yet added to the repo, it will create a component file for those.
1 parent d71fb68 commit 008d261

File tree

3 files changed

+364
-2
lines changed

3 files changed

+364
-2
lines changed

Gemfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,10 @@ group(:development, optional: true) do
2020
gem 'parallel', require: false
2121
gem 'colorize', require: false
2222
gem 'hashdiff', require: false
23+
gem 'tty-table', require: false
2324
end
2425

2526
group(:release, optional: true) do
2627
gem 'faraday-retry', '~> 2.1', require: false
2728
gem 'github_changelog_generator', '~> 1.16.4', require: false
2829
end
29-
30-
#gem 'rubocop', "~> 0.34.2"

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,47 @@ Where:
5555
the [configs/platforms](configs/platforms) directory
5656
- `target-vm` is the hostname of the VM you will build on. You must have root
5757
ssh access configured for this host, and it must match the target platform.
58+
59+
## Updating rubygem components
60+
61+
This repo includes a rake task that will use the RubyGems API to update all rubygem components, including adding any missing runtime dependencies.
62+
```
63+
$ bundle exec rake vox:update_gems
64+
```
65+
In each `rubygem-*.rb` file in `configs/components`, you will find a "magic" block near the top. For example:
66+
```
67+
### Maintained by update_gems automation ###
68+
pkg.version '2.14.0'
69+
pkg.sha256sum '8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd'
70+
pkg.build_requires 'rubygem-faraday-net_http'
71+
pkg.build_requires 'rubygem-json'
72+
pkg.build_requires 'rubygem-logger'
73+
### End automated maintenance section ###
74+
```
75+
Everything in this block can be automatically updated by the rake task. There are some special comments that change the behavior.
76+
77+
`# PINNED` right before the `pkg.version` line will keep this component at the current version. Dependencies will still be checked to ensure none are missing. For example:
78+
```
79+
### Maintained by update_gems automation ###
80+
# PINNED
81+
pkg.version '2.14.0'
82+
pkg.sha256sum '8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd'
83+
pkg.build_requires 'rubygem-faraday-net_http'
84+
pkg.build_requires 'rubygem-json'
85+
pkg.build_requires 'rubygem-logger'
86+
### End automated maintenance section ###
87+
```
88+
89+
Adding `# GEM TYPE: <type>` will allow you to specify a checksum for a precompiled version of a gem. This can be used with other logic within the magic block to specify a checksum based on platform. For example:
90+
```
91+
### Maintained by update_gems automation ###
92+
pkg.version '1.17.2'
93+
if platform.is_windows?
94+
# GEM TYPE: x64-mingw32
95+
pkg.sha256sum ''
96+
else
97+
pkg.sha256sum '297235842e5947cc3036ebe64077584bff583cd7a4e94e9a02fdec399ef46da6'
98+
end
99+
### End automated maintenance section ###
100+
```
101+
The rake task will leave any lines it doesn't know about alone (in this case, the if/else/end logic) and update both checksums, with the default without the `# GEM TYPE` decorator being the `ruby` uncompiled gem. Try not to get too fancy with logic in here.

tasks/update_gems.rake

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# frozen_string_literal: true
2+
3+
require 'rake'
4+
require 'json'
5+
require 'uri'
6+
require 'net/http'
7+
require 'rubygems/version'
8+
require 'rubygems/requirement'
9+
require 'colorize'
10+
require 'tty-table'
11+
require 'set'
12+
13+
# ----- Constants -----
14+
REPO_ROOT = File.expand_path('..', __dir__)
15+
COMPONENT_DIR = File.join(REPO_ROOT, 'configs', 'components')
16+
COMPONENT_GLOB = File.join(COMPONENT_DIR, 'rubygem-*.rb')
17+
18+
MAINT_START = /^\s*### Maintained by update_gems automation ###\s*$/
19+
MAINT_END = /^\s*### End automated maintenance section ###\s*$/
20+
PINNED_LINE = /^\s*#\s*PINNED\b.*$/
21+
VER_LINE = /^\s*pkg\.version\s+['"](?<version>[^'"]+)['"]\s*$/
22+
SHA_LINE = /^\s*pkg\.(?:sha256sum|md5sum)\s+['"](?<sha>[0-9a-fA-F]+)['"]\s*$/
23+
BUILD_REQ = /^\s*pkg\.build_requires\s+['"]rubygem-([^'"]+)['"]\s*$/
24+
GEM_TYPE = /^\s*#\s*GEM\s+TYPE:\s*(?<platform>[A-Za-z0-9\-_\.]+)\s*$/
25+
26+
TARGET_RUBY_VER = ENV['TARGET_RUBY']&.strip || '3.2'
27+
MAX_TABLE_WIDTH = 140
28+
VERSIONS_CACHE = {}
29+
30+
def create_component_file(path, gemname, version, sha, deps = [])
31+
lines = []
32+
lines << "#####\n"
33+
lines << "# Component release information:\n"
34+
lines << "# https://rubygems.org/gems/#{gemname}\n"
35+
lines << "#####\n"
36+
lines << "component 'rubygem-#{gemname}' do |pkg, settings, platform|\n"
37+
lines << " ### Maintained by update_gems automation ###\n"
38+
lines << " pkg.version '#{version}'\n"
39+
lines << " pkg.sha256sum '#{sha}'\n"
40+
deps.each { |name| lines << " pkg.build_requires 'rubygem-#{name}'\n" }
41+
lines << " ### End automated maintenance section ###\n"
42+
lines << "\n"
43+
lines << " instance_eval File.read('configs/components/_base-rubygem.rb')\n"
44+
lines << "end\n"
45+
File.write(path, lines.join, encoding: 'UTF-8')
46+
end
47+
48+
# ----- Table and progress output -----
49+
def color_status(s)
50+
case s
51+
when 'UP TO DATE' then s.green
52+
when 'UPDATED' then s.yellow
53+
when 'ADDED' then s.cyan
54+
when 'ERROR' then s.red
55+
when 'UNKNOWN' then s.red
56+
else s
57+
end
58+
end
59+
60+
def print_table(headers, rows)
61+
comp_w, status_w, version_w = 50, 12, 32
62+
deps_w = [MAX_TABLE_WIDTH - (comp_w + status_w + version_w + 13), 10].max
63+
table = TTY::Table.new headers, rows
64+
puts table.render(:ascii, width: MAX_TABLE_WIDTH, resize: true, multiline: true, padding: [0,1,0,1]) { |r|
65+
r.alignments = [:left, :center, :left, :left]
66+
r.border.separator = :each_row
67+
r.column_widths = [comp_w, status_w, version_w, deps_w]
68+
}
69+
end
70+
71+
@progress_max_width = 0
72+
def progress_print(msg, io: $stderr)
73+
clean = msg[0, MAX_TABLE_WIDTH - 1]
74+
@progress_max_width = [@progress_max_width, clean.length].max
75+
io.print("\r#{clean.ljust(@progress_max_width)}")
76+
io.flush
77+
end
78+
79+
def progress_clear(io: $stderr)
80+
return if @progress_max_width <= 0
81+
io.print("\r#{' ' * @progress_max_width}\r")
82+
io.flush
83+
@progress_max_width = 0
84+
end
85+
86+
# ----- Rubygems API access -----
87+
def http(url)
88+
uri = URI(url)
89+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |h|
90+
r = Net::HTTP::Get.new(uri)
91+
r['User-Agent'] = "openvox-runtime-script/1.0"
92+
res = h.request(r)
93+
raise "HTTP #{res.code} #{uri}" unless res.is_a?(Net::HTTPSuccess)
94+
res.body
95+
end
96+
end
97+
98+
def get_versions(name)
99+
VERSIONS_CACHE[name] ||= JSON.parse(http("https://rubygems.org/api/v1/versions/#{name}.json"))
100+
end
101+
102+
def get_version_details(name, version)
103+
enc = URI.encode_www_form_component(version.to_s)
104+
JSON.parse(http("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{enc}.json"))
105+
end
106+
107+
# ----- Fetching gem metadata -----
108+
def ruby_req_ok?(req_str)
109+
req = (req_str.nil? || req_str.strip.empty?) ? '>= 0' : req_str
110+
req = req.split(',').map(&:strip)
111+
Gem::Requirement.new(req).satisfied_by?(Gem::Version.new(TARGET_RUBY_VER))
112+
end
113+
114+
def find_sha(name, version, platform)
115+
# The v2 API only returns SHA for the "ruby" platform
116+
if platform.nil? || platform == '' || platform == 'ruby'
117+
details = get_version_details(name, version)
118+
raise "SHA not found in details for gem #{name} version #{version}" unless details['sha']
119+
return details['sha']
120+
end
121+
122+
# Otherwise, use the v1 API to find the specific platform
123+
list = get_versions(name)
124+
metadata = list.find { |v| v['number'] == version && v['platform'] == platform }
125+
raise "Version #{version} (platform: #{platform}) not found for gem #{name}" unless metadata
126+
raise "SHA not found in metadata for gem #{name} version #{version} platform #{platform}" unless metadata['sha']
127+
metadata['sha']
128+
end
129+
130+
def get_metadata(name:, version: nil, platforms: ['ruby'])
131+
all = get_versions(name)
132+
raise "No versions found for gem #{name}" if all.empty?
133+
134+
# Choose version, either the one passed in, or the latest compatible
135+
new_version = version
136+
unless new_version
137+
candidates = all.select do |v|
138+
next false if v['prerelease']
139+
next false if v['yanked']
140+
next false unless v['platform'].nil? || v['platform'] == 'ruby'
141+
ruby_req_ok?(v['ruby_version'])
142+
end
143+
raise "No compatible versions found for gem #{name}" if candidates.empty?
144+
new_version = candidates.max_by { |v| Gem::Version.new(v['number']) }['number']
145+
end
146+
147+
# Gather SHAs for requested platforms
148+
platforms = platforms.compact.uniq
149+
shas = {}
150+
platforms.each do |platform|
151+
shas[platform] = find_sha(name, new_version, platform)
152+
end
153+
154+
details = get_version_details(name, new_version)
155+
deps = (details.dig('dependencies', 'runtime') || [])
156+
157+
{ 'version' => new_version, 'shas' => shas, 'dependencies' => deps }
158+
end
159+
160+
# ----- Processing component files -----
161+
def process_component(path, gemname)
162+
lines = File.read(path, encoding: 'UTF-8').lines
163+
164+
# Find maintenance block
165+
start = lines.index { |l| l =~ MAINT_START } or raise "Automated maintenance section not found in #{path}"
166+
end_rel = lines[(start + 1)..].to_a.index { |l| l =~ MAINT_END } or raise "Automated maintenance section not closed in #{path}"
167+
finish = start + 1 + end_rel
168+
169+
body = lines[(start + 1)...finish]
170+
old_body_str = body.join
171+
172+
# First pass: read current version, pinned, deps, and platforms
173+
pinned = false
174+
current_version = nil
175+
old_deps = []
176+
platforms = ['ruby']
177+
prev = nil
178+
body.each do |line|
179+
if (m = line.match(VER_LINE))
180+
current_version = m[:version]
181+
pinned = !!(prev && prev =~ PINNED_LINE)
182+
elsif (m = line.match(BUILD_REQ))
183+
old_deps << m[1]
184+
elsif (m = line.match(GEM_TYPE))
185+
platforms << m[:platform]
186+
end
187+
prev = line
188+
end
189+
raise "pkg.version not found in maintenance section for #{path}" unless current_version
190+
191+
# Resolve target version, shas, and deps
192+
metadata = get_metadata(name: gemname, version: pinned ? current_version : nil, platforms: platforms.uniq)
193+
target_version = metadata['version']
194+
shas = metadata['shas']
195+
new_deps = metadata['dependencies'].map { |d| d['name'] }.uniq.sort - [gemname]
196+
newly_added = new_deps - old_deps
197+
198+
# Generate new block body
199+
new_body = []
200+
current_platform = nil
201+
body.each do |l|
202+
if (m = l.match(VER_LINE))
203+
new_body << l.sub(m[:version], target_version)
204+
elsif (m = l.match(GEM_TYPE))
205+
current_platform = m[:platform]
206+
new_body << l
207+
elsif (m = l.match(SHA_LINE))
208+
platform = current_platform || 'ruby'
209+
raise "No SHA found for platform #{platform} of gem #{gemname}" unless shas[platform]
210+
new_body << l.sub('md5sum', 'sha256sum').sub(m[:sha], shas[platform])
211+
current_platform = nil
212+
elsif l =~ BUILD_REQ
213+
# Drop existing build_requires
214+
next
215+
else
216+
new_body << l
217+
end
218+
end
219+
220+
# Append new build_requires at end of block body
221+
new_deps.each { |name| new_body << " pkg.build_requires 'rubygem-#{name}'\n" }
222+
223+
new_body_str = new_body.join
224+
block_changed = (old_body_str != new_body_str)
225+
lines[(start + 1)...finish] = new_body if block_changed
226+
227+
version_changed = (current_version != target_version)
228+
229+
File.write(path, lines.join, encoding: 'UTF-8') if block_changed
230+
231+
status = (version_changed || newly_added.any? || block_changed) ? 'UPDATED' : 'UP TO DATE'
232+
ver_col = version_changed ? "#{current_version} -> #{target_version}" : ''
233+
234+
# Report missing components so the task can create them
235+
missing = new_deps.select { |name| !File.exist?(File.join(COMPONENT_DIR, "rubygem-#{name}.rb")) }
236+
237+
{ name: gemname, status: status, version: ver_col, deps_added: newly_added.map { |n| "rubygem-#{n}" }, missing: missing }
238+
end
239+
240+
241+
namespace :vox do
242+
desc 'Update rubygem components and print a summary table or JSON of changes'
243+
task :update_gems do
244+
results = []
245+
files = Dir.glob(COMPONENT_GLOB).select { |p| File.file?(p) }
246+
total = files.length
247+
248+
files.each_with_index do |path, i|
249+
basename = File.basename(path, '.rb')
250+
gemname = basename.sub(/^rubygem-/, '')
251+
progress_print("Processing (#{i + 1}/#{total}): #{basename}")
252+
results << process_component(path, gemname)
253+
end
254+
progress_clear
255+
256+
# Create missing component files after processing all
257+
# Because some of the added components may have runtime
258+
# dependencies themselves that are also missing, we need
259+
# to keep a running queue of missing components to add.
260+
added = []
261+
queue = results.flat_map { |r| r[:missing] || [] }.uniq
262+
seen = Set.new
263+
until queue.empty?
264+
name = queue.shift
265+
next if seen.include?(name)
266+
seen << name
267+
268+
progress_print("Creating component for missing gem: #{name}")
269+
270+
path = File.join(COMPONENT_DIR, "rubygem-#{name}.rb")
271+
next if File.exist?(path)
272+
273+
m = get_metadata(name: name, platforms: ['ruby'])
274+
ver = m['version']
275+
sha = m['shas']['ruby']
276+
deps = (m['dependencies'] || []).map { |d| d['name'] }.uniq.sort - [name]
277+
278+
create_component_file(path, name, ver, sha, deps)
279+
added << { name: name, version: ver, deps: deps }
280+
281+
deps.each do |name|
282+
path = File.join(COMPONENT_DIR, "rubygem-#{name}.rb")
283+
queue << name unless File.exist?(path) || seen.include?(name) || queue.include?(name)
284+
end
285+
end
286+
progress_clear
287+
288+
# JSON output
289+
if !(ARGV & ['--json', '-j', '--format=json']).empty?
290+
payload = {
291+
ruby_version_used_for_checks: TARGET_RUBY_VER,
292+
results: results,
293+
added: added,
294+
}
295+
puts JSON.pretty_generate(payload)
296+
next
297+
end
298+
299+
# Table output
300+
headers = ['Component', 'Status', 'Version update', 'Dependencies added']
301+
rows = results.map do |r|
302+
[
303+
"rubygem-#{r[:name]}",
304+
color_status(r[:status]),
305+
r[:version],
306+
r[:deps_added].join(', '),
307+
]
308+
end
309+
added.each do |info|
310+
rows << [
311+
"rubygem-#{info[:name]}",
312+
color_status('ADDED'),
313+
info[:version].to_s,
314+
info[:deps].to_a.uniq.sort.map { |dn| "rubygem-#{dn}" }.join(', '),
315+
]
316+
end
317+
print_table(headers, rows)
318+
end
319+
end

0 commit comments

Comments
 (0)