Skip to content

Commit c418a23

Browse files
authored
Dev: Add periodic Gem freshness report (#4782)
1 parent c742206 commit c418a23

File tree

2 files changed

+170
-0
lines changed

2 files changed

+170
-0
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Gem freshness report
2+
3+
on:
4+
schedule:
5+
- cron: "0 8 * * 1" # Mondays 08:00 UTC
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read # to fetch code (actions/checkout)
10+
11+
jobs:
12+
report:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: hmarr/debug-action@v3
16+
- uses: actions/checkout@v6
17+
- uses: ./.github/workflows/composite/setup
18+
19+
- name: Generate report
20+
run: |
21+
bundle exec rake gem_freshness:report | tee gem_freshness_report.md
22+
cat gem_freshness_report.md >> "$GITHUB_STEP_SUMMARY"
23+
24+
- name: Upload report artifact
25+
uses: actions/upload-artifact@v6
26+
with:
27+
path: gem_freshness_report.md
28+
retention-days: 140

lib/tasks/gem_freshness.rake

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# rubocop:disable Metrics/BlockLength
2+
namespace :gem_freshness do
3+
desc 'Generate a report of gem freshness'
4+
task report: :environment do
5+
require 'bundler'
6+
require 'net/http'
7+
require 'oj'
8+
require 'date'
9+
require 'rubygems/version'
10+
11+
GEMFILE = ENV.fetch('GEMFILE', 'Gemfile')
12+
LOCKFILE = ENV.fetch('LOCKFILE', 'Gemfile.lock')
13+
14+
def http_get_json(url)
15+
uri = URI(url)
16+
res = Net::HTTP.get_response(uri)
17+
return nil unless res.is_a?(Net::HTTPSuccess)
18+
19+
Oj.load(res.body)
20+
rescue StandardError
21+
nil
22+
end
23+
24+
def rubygems_latest_info(name)
25+
http_get_json("https://rubygems.org/api/v1/gems/#{name}.json")
26+
end
27+
28+
def rubygems_versions(name)
29+
http_get_json("https://rubygems.org/api/v1/versions/#{name}.json")
30+
end
31+
32+
def parse_date(date)
33+
return nil if date.nil? || date.to_s.empty?
34+
35+
DateTime.parse(date).to_date
36+
rescue StandardError
37+
nil
38+
end
39+
40+
def fmt_date(date)
41+
date ? date.strftime('%Y-%m-%d') : '-'
42+
end
43+
44+
unless File.exist?(GEMFILE) && File.exist?(LOCKFILE)
45+
warn "Missing #{GEMFILE} or #{LOCKFILE} in current directory."
46+
exit 1
47+
end
48+
49+
definition = Bundler::Definition.build(GEMFILE, LOCKFILE, nil)
50+
direct_names = definition.dependencies.to_set(&:name)
51+
52+
lock = Bundler::LockfileParser.new(Bundler.read_file(LOCKFILE))
53+
locked_specs = lock.specs.sort_by(&:name)
54+
55+
rows = []
56+
total = 0
57+
outdated = 0
58+
no_ruby_gems_data = 0
59+
60+
locked_specs.each do |spec|
61+
name = spec.name
62+
current_v = Gem::Version.new(spec.version.to_s)
63+
64+
latest_info = rubygems_latest_info(name)
65+
versions = rubygems_versions(name)
66+
67+
total += 1
68+
69+
if latest_info.nil? || versions.nil?
70+
no_ruby_gems_data += 1
71+
rows << {
72+
type: direct_names.include?(name) ? 'direct' : 'transitive',
73+
name: name,
74+
current: current_v.to_s,
75+
current_date: nil,
76+
latest: nil,
77+
latest_date: nil,
78+
status: 'Could not fetch RubyGems data'
79+
}
80+
next
81+
end
82+
83+
latest_v_str = latest_info['version']
84+
latest_v = latest_v_str ? Gem::Version.new(latest_v_str) : nil
85+
86+
# versions endpoint returns array like:
87+
# [{"number":"x.y.z","created_at":"...","prerelease":false,...}, ...]
88+
by_number = {}
89+
versions.each do |v|
90+
num = v['number']
91+
by_number[num] = parse_date(v['created_at']) if num
92+
end
93+
94+
current_date = by_number[current_v.to_s]
95+
latest_date = parse_date(latest_info['version_created_at']) || (latest_v ? by_number[latest_v.to_s] : nil)
96+
97+
status =
98+
if latest_v && latest_v > current_v
99+
outdated += 1
100+
'OUTDATED'
101+
else
102+
'ok'
103+
end
104+
105+
rows << {
106+
type: direct_names.include?(name) ? 'direct' : 'transitive',
107+
name: name,
108+
current: current_v.to_s,
109+
current_date: current_date,
110+
latest: latest_v&.to_s,
111+
latest_date: latest_date,
112+
status: status
113+
}
114+
end
115+
116+
# Markdown output
117+
puts '# Ruby dependencies report'
118+
puts
119+
puts "- Generated: `#{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}`"
120+
puts "- Gemfile: `#{GEMFILE}`"
121+
puts "- Lockfile: `#{LOCKFILE}`"
122+
puts
123+
124+
puts '| Type | Gem | Current | Current released | Latest | Latest released | Status |'
125+
puts '|---|---|---:|---:|---:|---:|---|'
126+
127+
rows.each do |r|
128+
current_date = fmt_date(r[:current_date])
129+
latest_date = fmt_date(r[:latest_date])
130+
puts "| #{r[:type]} | `#{r[:name]}` | `#{r[:current]}` | #{current_date} | #{r[:latest] ? "`#{r[:latest]}`" : '-'} | #{latest_date} | #{r[:status]} |"
131+
end
132+
133+
puts
134+
puts '## Summary'
135+
puts
136+
puts "- Total gems: **#{total}**"
137+
puts "- Outdated: **#{outdated}**"
138+
puts "- No RubyGems API data: **#{no_ruby_gems_data}**"
139+
puts
140+
end
141+
end
142+
# rubocop:enable Metrics/BlockLength

0 commit comments

Comments
 (0)