diff options
Diffstat (limited to 'tool/update-NEWS-github-release.rb')
| -rwxr-xr-x | tool/update-NEWS-github-release.rb | 395 |
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 |
