Skip to content

Commit aec4b44

Browse files
committed
Add tool to update NEWS from GitHub releases
1 parent c57d594 commit aec4b44

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

tool/update-NEWS-github-release.rb

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env ruby
2+
3+
require "bundler/inline"
4+
require "json"
5+
require "net/http"
6+
require "uri"
7+
8+
gemfile do
9+
source "https://rubygems.org"
10+
gem "octokit"
11+
gem "faraday-retry"
12+
end
13+
14+
Octokit.configure do |c|
15+
c.access_token = ENV["GITHUB_TOKEN"]
16+
c.auto_paginate = true
17+
c.per_page = 100
18+
end
19+
20+
# Build a gem=>version map from stdgems.org stdgems.json for a given Ruby version (e.g., "3.4")
21+
def fetch_default_gems_versions(ruby_version)
22+
uri = URI.parse("https://stdgems.org/stdgems.json")
23+
json = JSON.parse(Net::HTTP.get(uri))
24+
gems = json["gems"] || []
25+
26+
map = {}
27+
gems.each do |g|
28+
# Only include default gems (skip ones marked removed)
29+
next if g["removed"]
30+
versions = g["versions"] || {}
31+
32+
# versions has "default" and "bundled" keys, each containing Ruby version => version mappings
33+
selected_version = nil
34+
35+
# Try both "default" and "bundled" categories
36+
["default", "bundled"].each do |category|
37+
category_versions = versions[category] || {}
38+
next if selected_version
39+
40+
if category_versions.key?(ruby_version)
41+
selected_version = category_versions[ruby_version]
42+
else
43+
# Fall back to the highest patch version matching the given major.minor
44+
major_minor = /^#{Regexp.escape(ruby_version)}\./
45+
candidates = category_versions.select { |k, _| k.match?(major_minor) }
46+
if !candidates.empty?
47+
# Sort keys as Gem::Version to pick the highest patch
48+
selected_version = candidates.sort_by { |k, _| Gem::Version.new(k) }.last[1]
49+
end
50+
end
51+
end
52+
53+
next unless selected_version
54+
55+
name = g["gem"]
56+
# Normalize name to match existing special cases
57+
name = "RubyGems" if name == "rubygems"
58+
map[name] = selected_version
59+
end
60+
61+
map
62+
end
63+
64+
# Load gem=>version map from a file or from stdgems.org if a Ruby version is given.
65+
def load_versions(arg)
66+
if arg.nil?
67+
abort "usage: #{File.basename($0)} FROM TO (each can be a file path or Ruby version like 3.4)"
68+
end
69+
if File.exist?(arg)
70+
File.readlines(arg).map(&:split).to_h
71+
elsif arg.match?(/^\d+\.\d+(?:\.\d+)?$/)
72+
fetch_default_gems_versions(arg)
73+
elsif arg.downcase == "news" || arg =~ %r{https?://.*/NEWS\.md}
74+
fetch_versions_to_from_news(arg)
75+
else
76+
abort "Invalid argument: #{arg}. Provide a file path or a Ruby version (e.g., 3.4)."
77+
end
78+
end
79+
80+
# Build a gem=>version map by parsing the "## Stdlib updates" section from Ruby's NEWS.md
81+
def fetch_versions_to_from_news(arg)
82+
url = arg.downcase == "news" ? "https://raw.githubusercontent.com/ruby/ruby/refs/heads/master/NEWS.md" : arg
83+
uri = URI.parse(url)
84+
body = Net::HTTP.get(uri)
85+
86+
# Extract the Stdlib updates section
87+
start_idx = body.index(/^## Stdlib updates$/)
88+
unless start_idx
89+
# Try a more lenient search if anchors differ
90+
start_idx = body.index("## Stdlib\nupdates") || body.index("## Stdlib updates")
91+
end
92+
abort "Stdlib updates section not found in NEWS.md" unless start_idx
93+
94+
section = body[start_idx..-1]
95+
# Stop at the next top-level section header (skip the current header line)
96+
first_line_len = section.lines.first ? section.lines.first.length : 0
97+
stop_idx = section.index(/^##\s+/, first_line_len)
98+
section = stop_idx ? section[0...stop_idx] : section
99+
100+
map = {}
101+
102+
# Normalize lines and collect bullet entries like: "* gemname x.y.z"
103+
section.each_line do |line|
104+
line = line.strip
105+
next unless line.start_with?("*")
106+
# Remove leading bullet
107+
entry = line.sub(/^\*\s+/, "")
108+
109+
# Some lines can include descriptions or links; we only take simple "name version"
110+
# Accept names with hyphens/underscores and versions like 1.2.3 or 1.2.3.4
111+
if entry =~ /^([A-Za-z0-9_\-]+)\s+(\d+(?:\.\d+){0,3})\b/
112+
name = $1
113+
ver = $2
114+
name = "RubyGems" if name.downcase == "rubygems"
115+
map[name] = ver
116+
end
117+
end
118+
119+
map
120+
end
121+
122+
versions_from = load_versions(ARGV[0])
123+
versions_to = load_versions("news")
124+
footnote_link = []
125+
126+
versions_to.each do |name, version|
127+
# Skip items which do not exist in the FROM map to reduce API calls
128+
next unless versions_from.key?(name)
129+
next if name == "RubyGems" || name == "bundler"
130+
131+
releases = []
132+
133+
case name
134+
when "minitest"
135+
repo = name
136+
org = "minitest"
137+
when "test-unit"
138+
repo = name
139+
org = "test-unit"
140+
when "bundler"
141+
repo = "rubygems"
142+
org = "ruby"
143+
else
144+
repo = name
145+
org = "ruby"
146+
end
147+
148+
Octokit.releases("#{org}/#{repo}").each do |release|
149+
releases << release.tag_name
150+
end
151+
152+
# Keep only version-like tags and sort descending by semantic version
153+
releases = releases.select { |t| t =~ /^v\d/ || t =~ /^\d/ || t =~ /^bundler-\d/ }
154+
releases = releases.sort_by { |t| Gem::Version.new(t.sub(/^bundler-/, "").sub(/^v/, "").tr("_", ".")) }
155+
156+
start_index = releases.index("v#{versions_from[name]}") || releases.index(versions_from[name]) || releases.index("bundler-v#{versions_from[name]}")
157+
end_index = releases.index("v#{versions_to[name]}") || releases.index(versions_to[name]) || releases.index("bundler-v#{versions_to[name]}")
158+
release_range = releases[start_index+1..end_index] if start_index && end_index
159+
160+
next unless release_range
161+
next if release_range.empty?
162+
163+
puts "* #{name} #{version}"
164+
puts " * #{versions_from[name]} to #{release_range.map { |rel|
165+
"[#{rel.sub(/^bundler-/, '')}][#{name}-#{rel.sub(/^bundler-/, '')}]"}.join(", ")}"
166+
release_range.each do |rel|
167+
footnote_link << "[#{name}-#{rel.sub(/^bundler-/, '')}]: https://github.com/#{org}/#{repo}/releases/tag/#{rel}"
168+
end
169+
end
170+
171+
puts footnote_link.join("\n")

0 commit comments

Comments
 (0)