summaryrefslogtreecommitdiff
path: root/tool/update-NEWS-github-release.rb
diff options
context:
space:
mode:
Diffstat (limited to 'tool/update-NEWS-github-release.rb')
-rwxr-xr-xtool/update-NEWS-github-release.rb395
1 files changed, 395 insertions, 0 deletions
diff --git a/tool/update-NEWS-github-release.rb b/tool/update-NEWS-github-release.rb
new file mode 100755
index 0000000000..f346209a7d
--- /dev/null
+++ b/tool/update-NEWS-github-release.rb
@@ -0,0 +1,395 @@
+#!/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