ruby/tool/update-NEWS-github-release.rb
Hiroshi SHIBATA 6e9ad14db9
Make NEWS.md release-link footer idempotent
Seed available_footnotes from the existing release-tag link defs in the
file before overlaying freshly fetched URLs, so gems whose Octokit
fetches are skipped this run keep their URLs instead of being stranded
at their old positions. With every used ref backed by a footnote, the
reject step can drop all release-tag defs unconditionally and re-emit
them in body-ref order, eliminating the back-and-forth reordering
between consecutive scheduled bot runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:15:49 +09:00

396 lines
12 KiB
Ruby
Executable File

#!/usr/bin/env ruby
require "bundler/inline"
require "json"
require "net/http"
require "uri"
gemfile do
source "https://rubygems.org"
gem "octokit"
gem "faraday-retry"
end
Octokit.configure do |c|
c.access_token = ENV["GITHUB_TOKEN"]
c.auto_paginate = true
c.per_page = 100
end
# Build a gem=>version map from stdgems.org stdgems.json for a given Ruby version (e.g., "3.4")
def fetch_default_gems_versions(ruby_version)
uri = URI.parse("https://stdgems.org/stdgems.json")
body = http_get(uri)
json = JSON.parse(body)
gems = json["gems"] || []
# Prefer the initial release key (e.g. "4.0.0") over the rolling
# major.minor key (e.g. "4.0") so the diff baseline reflects the original
# X.Y.0 release rather than the latest patch level.
initial_release_key = (ruby_version =~ /\A\d+\.\d+\z/) ? "#{ruby_version}.0" : nil
map = {}
gems.each do |g|
# Only include default gems (skip ones marked removed)
next if g["removed"]
versions = g["versions"] || {}
# versions has "default" and "bundled" keys, each containing Ruby version => version mappings
selected_version = nil
# Try both "default" and "bundled" categories
["default", "bundled"].each do |category|
category_versions = versions[category] || {}
next if selected_version
if initial_release_key && category_versions.key?(initial_release_key)
selected_version = category_versions[initial_release_key]
elsif category_versions.key?(ruby_version)
selected_version = category_versions[ruby_version]
else
# Fall back to the highest patch version matching the given major.minor
major_minor = /^#{Regexp.escape(ruby_version)}\./
candidates = category_versions.select { |k, _| k.match?(major_minor) }
if !candidates.empty?
# Sort keys as Gem::Version to pick the highest patch
selected_version = candidates.sort_by { |k, _| Gem::Version.new(k) }.last[1]
end
end
end
next unless selected_version
name = g["gem"]
# Normalize name to match existing special cases
name = "RubyGems" if name == "rubygems"
map[name] = selected_version
end
map
end
def previous_ruby_version
version_h = File.join(__dir__, "..", "include", "ruby", "version.h")
major = minor = nil
File.foreach(version_h) do |l|
major = $1.to_i if l =~ /^\s*#\s*define\s+RUBY_API_VERSION_MAJOR\s+(\d+)/
minor = $1.to_i if l =~ /^\s*#\s*define\s+RUBY_API_VERSION_MINOR\s+(\d+)/
end
abort "Cannot detect Ruby version from #{version_h}" unless major && minor
minor > 0 ? "#{major}.#{minor - 1}" : "#{major - 1}.0"
end
# Load gem=>version map from a file or from stdgems.org if a Ruby version is given.
def load_versions(arg)
arg ||= previous_ruby_version
if File.exist?(arg)
File.readlines(arg).map(&:split).to_h
elsif arg.match?(/^\d+\.\d+(?:\.\d+)?$/)
fetch_default_gems_versions(arg)
elsif arg.downcase == "news" || arg =~ %r{https?://.*/NEWS\.md}
fetch_versions_from_news(arg)
else
abort "Invalid argument: #{arg}. Provide a file path or a Ruby version (e.g., 3.4)."
end
end
# Build a gem=>version map by parsing the "## Stdlib updates" section from Ruby's NEWS.md
def fetch_versions_from_news(arg)
if arg.downcase == "news"
body = read_local_news_md
else
body = http_get(URI.parse(arg))
end
parse_stdlib_versions_from_news(body)
end
# Fetch a URL with a clear abort message on network or HTTP failures.
# Used for sources whose absence makes the rest of the script meaningless.
def http_get(uri)
res = Net::HTTP.get_response(uri)
unless res.is_a?(Net::HTTPSuccess)
abort "error: #{uri} returned HTTP #{res.code} #{res.message}"
end
res.body
rescue SystemCallError, SocketError, IOError, Net::HTTPError => e
abort "error: failed to fetch #{uri}: #{e.class}: #{e.message}"
end
def read_local_news_md
news_path = File.join(__dir__, "..", "NEWS.md")
unless File.exist?(news_path)
abort "NEWS.md not found at #{news_path}"
end
File.read(news_path)
end
# Build a gem=>version map from the current repository state. Default gems
# come from {ext,lib}/**/*.gemspec (mirroring default_gems_list.yml) and
# bundled gems come from gems/bundled_gems. This avoids reading NEWS.md as
# the source of "current versions", which would create a circular dependency
# with update-NEWS-gemlist.rb.
def load_current_versions
require "rubygems"
root = File.expand_path("..", __dir__)
map = {}
rg_path = File.join(root, "lib", "rubygems.rb")
if File.exist?(rg_path)
File.foreach(rg_path) do |line|
if /^\s*VERSION\s*=\s*"([^"]+)"/ =~ line
map["RubyGems"] = $1
break
end
end
end
Dir.glob(File.join(root, "{ext,lib}/**/*.gemspec")).each do |path|
spec = Gem::Specification.load(path)
next unless spec
map[spec.name] = spec.version.to_s
end
bundled_path = File.join(root, "gems", "bundled_gems")
if File.exist?(bundled_path)
File.foreach(bundled_path) do |line|
next if line.start_with?("#")
name, version = line.split(" ", 3)
map[name] = version if name && version
end
end
map
end
def parse_stdlib_versions_from_news(body)
# Extract the Stdlib updates section
start_idx = body.index(/^## Stdlib updates$/)
unless start_idx
# Try a more lenient search if anchors differ
start_idx = body.index("## Stdlib\nupdates") || body.index("## Stdlib updates")
end
abort "Stdlib updates section not found in NEWS.md" unless start_idx
section = body[start_idx..-1]
# Stop at the next top-level section header (skip the current header line)
first_line_len = section.lines.first ? section.lines.first.length : 0
stop_idx = section.index(/^##\s+/, first_line_len)
section = stop_idx ? section[0...stop_idx] : section
map = {}
# Normalize lines and collect bullet entries like: "* gemname x.y.z"
section.each_line do |line|
line = line.strip
next unless line.start_with?("*")
# Remove leading bullet
entry = line.sub(/^\*\s+/, "")
# Some lines can include descriptions or links; we only take simple "name version"
# Accept names with hyphens/underscores and versions like 1.2.3 or 1.2.3.4
if entry =~ /^([A-Za-z0-9_\-]+)\s+(\d+(?:\.\d+){0,3})\b/
name = $1
ver = $2
name = "RubyGems" if name.downcase == "rubygems"
map[name] = ver
end
end
map
end
def resolve_repo(name)
case name
when "minitest"
{ repo: name, org: "minitest" }
when "test-unit"
{ repo: name, org: "test-unit" }
when "RubyGems"
{ repo: "rubygems", org: "rubygems" }
when "bundler"
{ repo: "rubygems", org: "rubygems", tag_prefix: "bundler-" }
else
{ repo: name, org: "ruby" }
end
end
def fetch_release_range(name, from_version, to_version, org, repo, tag_prefix: "")
releases = []
begin
Octokit.releases("#{org}/#{repo}").each do |release|
releases << release.tag_name
end
rescue Octokit::Error, Faraday::Error => e
warn "warning: skipping #{name} (#{org}/#{repo}): #{e.class}: #{e.message}"
return nil
end
# Keep only this gem's version-like tags and sort ascending by semantic version
prefix = Regexp.escape(tag_prefix)
releases = releases.select { |t| t =~ /\A#{prefix}v?\d/ }
releases = releases.sort_by { |t| Gem::Version.new(t.sub(/\A#{prefix}/, "").sub(/^v/, "").tr("_", ".")) }
start_index = releases.index("#{tag_prefix}v#{from_version}") || releases.index("#{tag_prefix}#{from_version}")
end_index = releases.index("#{tag_prefix}v#{to_version}") || releases.index("#{tag_prefix}#{to_version}")
# If the "to" version is unreleased (e.g. 4.1.0.dev), include every released
# tag after the baseline up to the latest one available.
end_index ||= releases.length - 1 if to_version =~ /(?:\.|-)(?:dev|beta|alpha|rc|pre)/i
return nil unless start_index && end_index
range = releases[start_index + 1..end_index]
return nil if range.nil? || range.empty?
range
end
def collect_gem_updates(versions_from, versions_to)
results = []
versions_to.each do |name, version|
# Skip items which do not exist in the FROM map to reduce API calls
next unless versions_from.key?(name)
info = resolve_repo(name)
org = info[:org]
repo = info[:repo]
tag_prefix = info[:tag_prefix] || ""
release_range = fetch_release_range(name, versions_from[name], version, org, repo, tag_prefix: tag_prefix)
next unless release_range
footnote_links = release_range.map do |rel|
tag = rel.sub(/\A#{Regexp.escape(tag_prefix)}/, "")
{
ref: "#{name}-#{tag}",
url: "https://github.com/#{org}/#{repo}/releases/tag/#{rel}",
}
end
results << {
name: name,
version: version,
from_version: versions_from[name],
release_range: release_range,
footnote_links: footnote_links,
tag_prefix: tag_prefix,
}
end
results
end
def format_release_diff(result)
prefix = Regexp.escape(result[:tag_prefix] || "")
links = result[:release_range].map do |rel|
tag = rel.sub(/\A#{prefix}/, "")
"[#{tag}][#{result[:name]}-#{tag}]"
end
" * #{result[:from_version]} to #{links.join(', ')}"
end
def print_results(results)
footnote_lines = []
results.each do |r|
puts "* #{r[:name]} #{r[:version]}"
puts format_release_diff(r)
r[:footnote_links].each do |fl|
footnote_lines << "[#{fl[:ref]}]: #{fl[:url]}"
end
end
puts footnote_lines.join("\n")
end
def update_news_md(results)
news_path = File.join(__dir__, "..", "NEWS.md")
unless File.exist?(news_path)
abort "NEWS.md not found at #{news_path}"
end
content = File.read(news_path)
lines = content.lines
result_by_name = results.to_h { |r| [r[:name], r] }
new_lines = []
i = 0
while i < lines.length
line = lines[i]
if line =~ /^\* ([A-Za-z0-9_\-]+)\s+(\d+(?:\.\d+){0,3})\b/
gem_name = $1
new_lines << line
if (r = result_by_name[gem_name])
# Skip any existing sub-bullet lines that follow
while i + 1 < lines.length && lines[i + 1] =~ /^\s+\*/
i += 1
end
new_lines << "#{format_release_diff(r)}\n"
end
else
new_lines << line
end
i += 1
end
# All footnote definitions we can emit, indexed by ref name. Seed from existing
# release-tag defs in the file so gems skipped this run (e.g. transient API
# failures) keep their URLs, then overlay freshly fetched URLs.
release_ref_pattern = %r{^\[([^\]]+)\]:\s+(https://github\.com/[^/]+/[^/]+/releases/tag/.*)}
available_footnotes = {}
new_lines.each do |line|
if (m = line.match(release_ref_pattern))
available_footnotes[m[1]] = "[#{m[1]}]: #{m[2]}"
end
end
results.each do |r|
r[:footnote_links].each do |fl|
available_footnotes[fl[:ref]] = "[#{fl[:ref]}]: #{fl[:url]}"
end
end
# Refs the regenerated body actually uses (e.g. `][gem-vX.Y.Z]`)
used_refs = new_lines.join.scan(/\]\[([^\]]+)\]/).flatten.uniq
# Drop all existing GitHub release-tag link defs; the used subset is
# re-emitted below in body-ref order so the footer is deterministic.
new_lines.reject! { |line| line.match?(release_ref_pattern) }
# Trim trailing blank lines so the appended footer block is clean
new_lines.pop while new_lines.last == "\n"
new_lines << "\n" unless new_lines.last&.end_with?("\n")
# Append footnote defs only for refs the body still references
emitted = 0
used_refs.each do |ref|
if (footnote = available_footnotes[ref])
new_lines << "#{footnote}\n"
emitted += 1
end
end
File.write(news_path, new_lines.join)
puts "Updated #{news_path} with #{results.length} gem update entries and #{emitted} footnote links."
end
# --- Main ---
update_mode = ARGV.delete("--update")
versions_from = load_versions(ARGV[0])
versions_to = load_current_versions
results = collect_gem_updates(versions_from, versions_to)
print_results(results)
if update_mode
update_news_md(results)
end