diff --git a/.github/scripts/release-version-advisory.rb b/.github/scripts/release-version-advisory.rb new file mode 100644 index 0000000..e0f55d7 --- /dev/null +++ b/.github/scripts/release-version-advisory.rb @@ -0,0 +1,454 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# release-version-advisory.rb +# +# Advisory-only change detector for release-version selection. +# Design brief: https://github.com/metanorma/ci/issues/369 +# +# Invoked by the version-advisory-action composite (never curl'd from main). +# Emits a markdown table to $GITHUB_STEP_SUMMARY and a ::notice:: with the +# overall suggested bump. Never blocks — exit 0 always. +# +# Principles (ci#369 AC): +# P1 Advisory only. Rescue clause guarantees exit 0. +# P2 Opt-in via version_advisory input on rubygems-release.yml. +# P4 Public vs internal classification with per-row evidence. +# P5 Wrapper-friendly. Output only via $GITHUB_STEP_SUMMARY + ::notice::. +# P6 Cheap and deterministic. Prism + git. No network. +# +# Phase-2 limitations (documented, not blockers): +# - class << self blocks not distinguished from instance methods +# - attr_accessor / attr_reader / attr_writer not extracted +# - Inheritance / include / extend changes not detected +# - define_method / class_eval metaprogramming not covered +# - public_api.txt is read at HEAD (not at prev_tag) + +require "prism" +require "open3" +require "digest" + +module VersionAdvisory + # Ordered signals, first-match-wins. Default = :unknown. + module ApiSurface + ANNOTATION_PUBLIC = /^\s*#\s*@api\s+public\b/ + ANNOTATION_PRIVATE = /^\s*#\s*@api\s+private\b/ + YARD_METHOD_TAG = /^\s*#\s*@!method\b/ + NODOC = /^\s*#\s*:nodoc:/ + + INTERNAL_MODULE_NAMES = %w[Internal Private].freeze + INTERNAL_DIR_SEGMENTS = %w[internal private].freeze + + def self.classify(symbol_info) + comments = symbol_info[:comment_block] || [] + + if comments.any? { |c| c.match?(ANNOTATION_PUBLIC) } + return [:public, "annotation `# @api public` at #{symbol_info[:file]}:#{symbol_info[:line]}"] + end + if comments.any? { |c| c.match?(ANNOTATION_PRIVATE) } + return [:internal, "annotation `# @api private` at #{symbol_info[:file]}:#{symbol_info[:line]}"] + end + if comments.any? { |c| c.match?(NODOC) } + return [:internal, "`:nodoc:` at #{symbol_info[:file]}:#{symbol_info[:line]}"] + end + if comments.any? { |c| c.match?(YARD_METHOD_TAG) } + return [:public, "Yard `# @!method` at #{symbol_info[:file]}:#{symbol_info[:line]}"] + end + + contract = load_contract_file + if contract && !contract.empty? + fqn = symbol_info[:namespace_path] + return [:public, "listed in public_api.txt"] if contract.include?(fqn) + + return [:internal, "not listed in public_api.txt"] + end + + ns = symbol_info[:namespace_path] + if ns && INTERNAL_MODULE_NAMES.any? { |m| ns.include?("::#{m}::") || ns.start_with?("#{m}::") } + return [:internal, "in `Internal`/`Private` namespace (#{ns})"] + end + if symbol_info[:name].to_s.start_with?("_") + return [:internal, "leading-underscore name (#{symbol_info[:name]})"] + end + if INTERNAL_DIR_SEGMENTS.any? { |seg| symbol_info[:file].to_s.include?("/#{seg}/") } + return [:internal, "under lib/*/{internal,private}/ path (#{symbol_info[:file]})"] + end + + [:unknown, "no api-surface signal found; consider marking with `# @api`"] + end + + def self.load_contract_file + return @contract if defined?(@contract) + + path = "public_api.txt" + @contract = if File.exist?(path) + File.readlines(path, chomp: true).reject { |l| l.empty? || l.start_with?("#") } + end + end + + def self.reset_contract_cache! + remove_instance_variable(:@contract) if defined?(@contract) + end + end + + module DiffRange + def self.discover + head_sha, head_status = run_git("rev-parse", "HEAD", allow_failure: true) + return [nil, nil, nil] unless head_status.success? + + head_sha = head_sha.strip + + prev_tag, tag_status = run_git("describe", "--tags", "--abbrev=0", allow_failure: true) + return [nil, head_sha, nil] unless tag_status.success? + + prev_tag = prev_tag.strip + return [nil, head_sha, nil] if prev_tag.empty? + + prev_sha, sha_status = run_git("rev-parse", "#{prev_tag}^{commit}", allow_failure: true) + return [nil, head_sha, nil] unless sha_status.success? + + [prev_sha.strip, head_sha, prev_tag] + end + + def self.changed_lib_files(prev_sha, head_sha) + return [] unless prev_sha && head_sha + + out, status = run_git("diff", "--name-only", "#{prev_sha}...#{head_sha}", "--", "lib/", allow_failure: true) + return [] unless status.success? + + out.strip.split("\n").reject(&:empty?) + end + + def self.file_at_sha(sha, path) + out, status = run_git("show", "#{sha}:#{path}", allow_failure: true) + status.success? ? out : nil + end + + def self.run_git(*args, allow_failure: false) + out, err, status = Open3.capture3("git", *args) + warn "git #{args.join(' ')} failed: #{err}" if !status.success? && !allow_failure + [out, status] + end + end + + module Extractor + SymbolInfo = Struct.new( + :kind, :name, :namespace_path, :file, :line, + :comment_block, :signature, :body_fingerprint, + keyword_init: true, + ) + + def self.extract(source, file_path) + return [] if source.nil? || source.empty? + + parse_result = Prism.parse(source) + return [] if parse_result.failure? + + symbols = [] + visitor = Visitor.new(file_path, source, symbols) + visitor.visit(parse_result.value) + symbols + end + + # Proper Prism::Visitor subclass — no string-mangled send/respond_to? dispatch. + class Visitor < Prism::Visitor + def initialize(file_path, source, out) + super() + @file_path = file_path + @source_lines = source.split("\n") + @out = out + @namespace_stack = [] + end + + def visit_class_node(node) + name = node_name(node.constant_path) + @namespace_stack.push(name) + emit(:class, name, node) + super + @namespace_stack.pop + end + + def visit_module_node(node) + name = node_name(node.constant_path) + @namespace_stack.push(name) + emit(:module, name, node) + super + @namespace_stack.pop + end + + def visit_def_node(node) + name = node.name.to_s + emit(:method, name, node, + signature: method_signature(node), + body_fingerprint: body_fingerprint(node)) + # Do not descend into method bodies + end + + def visit_constant_write_node(node) + emit(:constant, node.name.to_s, node) + end + + private + + def emit(kind, name, node, signature: nil, body_fingerprint: nil) + line = node.location.start_line + @out << SymbolInfo.new( + kind: kind, + name: name, + namespace_path: (@namespace_stack + [name]).join("::"), + file: @file_path, + line: line, + comment_block: preceding_comment_block(line), + signature: signature, + body_fingerprint: body_fingerprint, + ) + end + + # Fingerprint ignores trailing comments and blank lines. Leading + # whitespace is preserved (re-indent of a method body is a real change + # for heredocs / %w[] etc. and is intentionally detected). + def body_fingerprint(def_node) + loc = def_node.location + source_lines = (loc.start_line..loc.end_line).map { |ln| @source_lines[ln - 1] || "" } + normalised = source_lines + .map { |l| l.sub(/#.*$/, "").rstrip } + .reject { |l| l.strip.empty? } + .join("\n") + Digest::SHA1.hexdigest(normalised) + end + + def preceding_comment_block(target_line) + block = [] + i = target_line - 2 + while i >= 0 + l = @source_lines[i] + break unless l&.match?(/^\s*#/) + + block.unshift(l) + i -= 1 + end + block + end + + def method_signature(def_node) + return {} unless def_node.parameters + + params = def_node.parameters + { + required: (params.requireds || []).map { |p| param_name(p) }, + optional: (params.optionals || []).map { |p| param_name(p) }, + rest: params.rest ? param_name(params.rest) : nil, + keywords: (params.keywords || []).map { |p| param_name(p) }, + keyword_rest: params.keyword_rest ? param_name(params.keyword_rest) : nil, + block: params.block ? param_name(params.block) : nil, + } + end + + def param_name(param_node) + param_node.respond_to?(:name) ? param_node.name.to_s : "?" + end + + def node_name(const_path_node) + return "?" unless const_path_node + return const_path_node.name.to_s if const_path_node.respond_to?(:name) && !const_path_node.respond_to?(:parent) + + parts = [] + current = const_path_node + while current.respond_to?(:parent) && current.parent + parts.unshift(current.name.to_s) + current = current.parent + end + parts.unshift(current.name.to_s) if current.respond_to?(:name) + parts.join("::") + end + end + end + + module Diff + Change = Struct.new(:kind, :symbol_before, :symbol_after, keyword_init: true) + + def self.compute(before_symbols, after_symbols) + changes = [] + # .last wins for reopened-class redefinitions (the active definition). + before_by_fqn = before_symbols.group_by { |s| [s.kind, s.namespace_path] } + .transform_values(&:last) + after_by_fqn = after_symbols.group_by { |s| [s.kind, s.namespace_path] } + .transform_values(&:last) + + (before_by_fqn.keys | after_by_fqn.keys).each do |key| + before = before_by_fqn[key] + after = after_by_fqn[key] + + if before && !after + changes << Change.new(kind: :removed, symbol_before: before, symbol_after: nil) + elsif !before && after + changes << Change.new(kind: :added, symbol_before: nil, symbol_after: after) + elsif before && after && before.kind == :method + if signature_broken?(before.signature, after.signature) + changes << Change.new(kind: :signature_changed, symbol_before: before, symbol_after: after) + elsif before.body_fingerprint && after.body_fingerprint && + before.body_fingerprint != after.body_fingerprint + changes << Change.new(kind: :body_changed_maybe, symbol_before: before, symbol_after: after) + end + end + end + + changes + end + + def self.signature_broken?(before_sig, after_sig) + return false unless before_sig && after_sig + return true if (before_sig[:required] || []) != (after_sig[:required] || []) + return true if ((before_sig[:keywords] || []) - (after_sig[:keywords] || [])).any? + + false + end + end + + module Bucket + Row = Struct.new(:change, :api_classification, :evidence, :suggested_bump, keyword_init: true) + + ORDER = %i[none patch minor major unknown].freeze + + # OCP lookup: [change.kind, api_class] → bump. Exhaustive for known pairs. + BUMP_TABLE = { + [:added, :public] => :minor, + [:added, :internal] => :patch, + [:added, :unknown] => :patch, + [:removed, :public] => :major, + [:removed, :internal] => :patch, + [:removed, :unknown] => :unknown, + [:signature_changed, :public] => :major, + [:signature_changed, :internal] => :patch, + [:signature_changed, :unknown] => :unknown, + [:body_changed_maybe, :public] => :unknown, + [:body_changed_maybe, :internal] => :patch, + [:body_changed_maybe, :unknown] => :unknown, + }.freeze + + def self.classify_all(changes, pre_1_0: false) + rows = changes.map { |ch| classify_one(ch, pre_1_0: pre_1_0) } + overall = rows.map(&:suggested_bump).max_by { |b| ORDER.index(b) || -1 } || :none + [rows, overall] + end + + def self.classify_one(change, pre_1_0: false) + symbol = change.symbol_after || change.symbol_before + api_class, evidence = ApiSurface.classify(symbol_info_for(symbol)) + bump = BUMP_TABLE[[change.kind, api_class]] || :none + + if pre_1_0 && bump == :major + bump = :minor + evidence = "#{evidence}; demoted major→minor per pre-1.0 SemVer convention" + end + + Row.new(change: change, api_classification: api_class, evidence: evidence, suggested_bump: bump) + end + + def self.symbol_info_for(symbol) + { + name: symbol.name, + namespace_path: symbol.namespace_path, + file: symbol.file, + line: symbol.line, + comment_block: symbol.comment_block, + } + end + end + + module Output + def self.emit_summary(rows, overall_bump, prev_tag, requested_bump) + summary_path = ENV["GITHUB_STEP_SUMMARY"] + return unless summary_path + + File.open(summary_path, "a") { |f| f.write(build_markdown(rows, overall_bump, prev_tag, requested_bump)) } + end + + def self.emit_notice(overall_bump, requested_bump) + msg = if requested_bump && overall_bump.to_s != requested_bump.to_s + "Detected changes suggest '#{overall_bump}'. You selected '#{requested_bump}'. See run summary." + else + "Detected changes suggest '#{overall_bump}'." + end + warn "::notice title=Version advisory::#{msg}" + end + + def self.build_markdown(rows, overall_bump, prev_tag, requested_bump) + out = +"\n## Version advisory\n\n" + out << "Range: #{prev_tag || 'first release'} → HEAD\n" + out << "Overall suggested bump: **#{overall_bump}**\n" + out << "You selected: `#{requested_bump || 'skip/unspecified'}`.\n\n" + + if rows.empty? + out << "_No lib/ symbol changes detected._\n" + return out + end + + out << "| Change | Classification | Evidence | Suggested |\n" + out << "|---|---|---|---|\n" + rows.each do |row| + out << "| #{describe_change(row.change)} | #{row.api_classification} | #{row.evidence} | #{row.suggested_bump} |\n" + end + out << "\n_Advisory only. Maintainer selects the bump. See ci#369 for the design brief._\n" + out + end + + def self.describe_change(change) + case change.kind + when :added then "Added `#{change.symbol_after.namespace_path}`" + when :removed then "Removed `#{change.symbol_before.namespace_path}`" + when :signature_changed then "Signature-changed `#{change.symbol_after.namespace_path}`" + when :body_changed_maybe then "Body-changed (undetectable) `#{change.symbol_after.namespace_path}`" + end + end + end + + def self.run(requested_bump: ENV["ADVISORY_REQUESTED_BUMP"]) + prev_sha, head_sha, prev_tag = DiffRange.discover + + unless head_sha + warn "::notice title=Version advisory::advisory could not resolve HEAD; skipping (advisory is non-blocking)" + return 0 + end + + unless prev_sha + warn "::notice title=Version advisory::first release (or no prior tag); no advisory diff available" + return 0 + end + + changed_files = DiffRange.changed_lib_files(prev_sha, head_sha) + if changed_files.empty? + Output.emit_summary([], :none, prev_tag, requested_bump) + Output.emit_notice(:none, requested_bump) + return 0 + end + + all_before = [] + all_after = [] + changed_files.each do |path| + before_src = DiffRange.file_at_sha(prev_sha, path) + after_src = DiffRange.file_at_sha(head_sha, path) + all_before.concat(Extractor.extract(before_src, path)) if before_src + all_after.concat(Extractor.extract(after_src, path)) if after_src + end + + changes = Diff.compute(all_before, all_after) + rows, overall_bump = Bucket.classify_all(changes, pre_1_0: pre_1_0_tag?(prev_tag)) + Output.emit_summary(rows, overall_bump, prev_tag, requested_bump) + Output.emit_notice(overall_bump, requested_bump) + 0 + rescue StandardError => e + warn "::warning title=Version advisory crashed::#{e.class}: #{e.message}" + warn e.backtrace.first(5).join("\n") + 0 + end + + def self.pre_1_0_tag?(tag) + return false unless tag + + m = tag.to_s.match(/\Av?(\d+)\./) + m && m[1] == "0" + end +end + +exit VersionAdvisory.run if $PROGRAM_NAME == __FILE__ diff --git a/.github/scripts/test-fixtures/version-advisory/a-internal-removal/after.rb b/.github/scripts/test-fixtures/version-advisory/a-internal-removal/after.rb new file mode 100644 index 0000000..6b1964b --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/a-internal-removal/after.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.public_thing(x) + x + 1 + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/a-internal-removal/before.rb b/.github/scripts/test-fixtures/version-advisory/a-internal-removal/before.rb new file mode 100644 index 0000000..2d121cd --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/a-internal-removal/before.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Foo + # @api private + def self.internal_helper(x) + x * 2 + end + + # @api public + def self.public_thing(x) + x + 1 + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/a-internal-removal/expected.md b/.github/scripts/test-fixtures/version-advisory/a-internal-removal/expected.md new file mode 100644 index 0000000..e9bcc9a --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/a-internal-removal/expected.md @@ -0,0 +1,14 @@ +::notice title=Version advisory::Detected changes suggest 'patch'. + + +## Version advisory + +Range: v0.1.0 → HEAD +Overall suggested bump: **patch** +You selected: `patch`. + +| Change | Classification | Evidence | Suggested | +|---|---|---|---| +| Removed `Foo::internal_helper` | internal | annotation `# @api private` at lib/foo.rb:5 | patch | + +_Advisory only. Maintainer selects the bump. See ci#369 for the design brief._ diff --git a/.github/scripts/test-fixtures/version-advisory/b-public-addition/after.rb b/.github/scripts/test-fixtures/version-advisory/b-public-addition/after.rb new file mode 100644 index 0000000..50b602e --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/b-public-addition/after.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.existing_method(x) + x + end + + # @api public + def self.new_public_method(x, y) + x + y + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/b-public-addition/before.rb b/.github/scripts/test-fixtures/version-advisory/b-public-addition/before.rb new file mode 100644 index 0000000..df907e6 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/b-public-addition/before.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.existing_method(x) + x + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/b-public-addition/expected.md b/.github/scripts/test-fixtures/version-advisory/b-public-addition/expected.md new file mode 100644 index 0000000..e1ad748 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/b-public-addition/expected.md @@ -0,0 +1,14 @@ +::notice title=Version advisory::Detected changes suggest 'minor'. You selected 'patch'. See run summary. + + +## Version advisory + +Range: v0.1.0 → HEAD +Overall suggested bump: **minor** +You selected: `patch`. + +| Change | Classification | Evidence | Suggested | +|---|---|---|---| +| Added `Foo::new_public_method` | public | annotation `# @api public` at lib/foo.rb:10 | minor | + +_Advisory only. Maintainer selects the bump. See ci#369 for the design brief._ diff --git a/.github/scripts/test-fixtures/version-advisory/c-public-body-change/after.rb b/.github/scripts/test-fixtures/version-advisory/c-public-body-change/after.rb new file mode 100644 index 0000000..1f5b235 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/c-public-body-change/after.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.public_thing(x) + x * 2 + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/c-public-body-change/before.rb b/.github/scripts/test-fixtures/version-advisory/c-public-body-change/before.rb new file mode 100644 index 0000000..6b1964b --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/c-public-body-change/before.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.public_thing(x) + x + 1 + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/c-public-body-change/expected.md b/.github/scripts/test-fixtures/version-advisory/c-public-body-change/expected.md new file mode 100644 index 0000000..502b497 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/c-public-body-change/expected.md @@ -0,0 +1,14 @@ +::notice title=Version advisory::Detected changes suggest 'unknown'. You selected 'patch'. See run summary. + + +## Version advisory + +Range: v0.1.0 → HEAD +Overall suggested bump: **unknown** +You selected: `patch`. + +| Change | Classification | Evidence | Suggested | +|---|---|---|---| +| Body-changed (undetectable) `Foo::public_thing` | public | annotation `# @api public` at lib/foo.rb:5 | unknown | + +_Advisory only. Maintainer selects the bump. See ci#369 for the design brief._ diff --git a/.github/scripts/test-fixtures/version-advisory/d-no-change/after.rb b/.github/scripts/test-fixtures/version-advisory/d-no-change/after.rb new file mode 100644 index 0000000..b83af9c --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/d-no-change/after.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Foo + # @api public + # Updated comment describing the method a bit more. + def self.method_a(x) + x + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/d-no-change/before.rb b/.github/scripts/test-fixtures/version-advisory/d-no-change/before.rb new file mode 100644 index 0000000..be9fe8a --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/d-no-change/before.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.method_a(x) + x + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/d-no-change/expected.md b/.github/scripts/test-fixtures/version-advisory/d-no-change/expected.md new file mode 100644 index 0000000..acf34c4 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/d-no-change/expected.md @@ -0,0 +1,10 @@ +::notice title=Version advisory::Detected changes suggest 'none'. You selected 'patch'. See run summary. + + +## Version advisory + +Range: v0.1.0 → HEAD +Overall suggested bump: **none** +You selected: `patch`. + +_No lib/ symbol changes detected._ diff --git a/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/after.rb b/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/after.rb new file mode 100644 index 0000000..4d8ec2d --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/after.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.transform(x) + x * 2 + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/before.rb b/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/before.rb new file mode 100644 index 0000000..b8afe19 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/before.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.transform(x, mode) + mode == :double ? x * 2 : x + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/expected.md b/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/expected.md new file mode 100644 index 0000000..df702a0 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/e-public-signature-broken/expected.md @@ -0,0 +1,14 @@ +::notice title=Version advisory::Detected changes suggest 'minor'. You selected 'patch'. See run summary. + + +## Version advisory + +Range: v0.1.0 → HEAD +Overall suggested bump: **minor** +You selected: `patch`. + +| Change | Classification | Evidence | Suggested | +|---|---|---|---| +| Signature-changed `Foo::transform` | public | annotation `# @api public` at lib/foo.rb:5; demoted major→minor per pre-1.0 SemVer convention | minor | + +_Advisory only. Maintainer selects the bump. See ci#369 for the design brief._ diff --git a/.github/scripts/test-fixtures/version-advisory/f-public-removal/after.rb b/.github/scripts/test-fixtures/version-advisory/f-public-removal/after.rb new file mode 100644 index 0000000..6b1964b --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/f-public-removal/after.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.public_thing(x) + x + 1 + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/f-public-removal/before.rb b/.github/scripts/test-fixtures/version-advisory/f-public-removal/before.rb new file mode 100644 index 0000000..f85561a --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/f-public-removal/before.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.public_thing(x) + x + 1 + end + + # @api public + def self.doomed(x) + x + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/f-public-removal/expected.md b/.github/scripts/test-fixtures/version-advisory/f-public-removal/expected.md new file mode 100644 index 0000000..d619d90 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/f-public-removal/expected.md @@ -0,0 +1,14 @@ +::notice title=Version advisory::Detected changes suggest 'minor'. You selected 'patch'. See run summary. + + +## Version advisory + +Range: v0.1.0 → HEAD +Overall suggested bump: **minor** +You selected: `patch`. + +| Change | Classification | Evidence | Suggested | +|---|---|---|---| +| Removed `Foo::doomed` | public | annotation `# @api public` at lib/foo.rb:10; demoted major→minor per pre-1.0 SemVer convention | minor | + +_Advisory only. Maintainer selects the bump. See ci#369 for the design brief._ diff --git a/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/after.rb b/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/after.rb new file mode 100644 index 0000000..6b1964b --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/after.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Foo + # @api public + def self.public_thing(x) + x + 1 + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/before.rb b/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/before.rb new file mode 100644 index 0000000..1431501 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/before.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Foo + # :nodoc: + def self.hidden_helper(x) + x * 2 + end + + # @api public + def self.public_thing(x) + x + 1 + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/expected.md b/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/expected.md new file mode 100644 index 0000000..5304d21 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/g-nodoc-removal/expected.md @@ -0,0 +1,14 @@ +::notice title=Version advisory::Detected changes suggest 'patch'. + + +## Version advisory + +Range: v0.1.0 → HEAD +Overall suggested bump: **patch** +You selected: `patch`. + +| Change | Classification | Evidence | Suggested | +|---|---|---|---| +| Removed `Foo::hidden_helper` | internal | `:nodoc:` at lib/foo.rb:5 | patch | + +_Advisory only. Maintainer selects the bump. See ci#369 for the design brief._ diff --git a/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/after.rb b/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/after.rb new file mode 100644 index 0000000..2652e50 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/after.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Foo + def self.listed(x) + x + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/before.rb b/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/before.rb new file mode 100644 index 0000000..c0b021a --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/before.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Foo + def self.listed(x) + x + end + + def self.unlisted(x) + x + end +end diff --git a/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/expected.md b/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/expected.md new file mode 100644 index 0000000..3cf092e --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/expected.md @@ -0,0 +1,14 @@ +::notice title=Version advisory::Detected changes suggest 'patch'. + + +## Version advisory + +Range: v0.1.0 → HEAD +Overall suggested bump: **patch** +You selected: `patch`. + +| Change | Classification | Evidence | Suggested | +|---|---|---|---| +| Removed `Foo::unlisted` | internal | not listed in public_api.txt | patch | + +_Advisory only. Maintainer selects the bump. See ci#369 for the design brief._ diff --git a/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/public_api.txt b/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/public_api.txt new file mode 100644 index 0000000..f538406 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/h-public-api-txt/public_api.txt @@ -0,0 +1,2 @@ +# Contract file — listed symbols are public +Foo::listed diff --git a/.github/scripts/test-fixtures/version-advisory/run-fixtures.sh b/.github/scripts/test-fixtures/version-advisory/run-fixtures.sh new file mode 100755 index 0000000..489a391 --- /dev/null +++ b/.github/scripts/test-fixtures/version-advisory/run-fixtures.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# run-fixtures.sh — drive release-version-advisory.rb against each fixture +# directory and assert the output matches expected.md. +# +# Usage: +# bash run-fixtures.sh # assert all fixtures +# bash run-fixtures.sh # assert one fixture +# bash run-fixtures.sh --generate-golden # save current output as +# expected.md for each fixture +# (bootstrap; commit the result) +# +# For each fixture directory: +# 1. Create a temp git repo. +# 2. Commit before.rb as lib/foo.rb; tag as v0.1.0. +# 3. Commit after.rb as lib/foo.rb. +# 4. Run advisory; capture $GITHUB_STEP_SUMMARY + the ::notice:: line. +# 5. Concatenate as: "\n\n" into actual.txt. +# 6. Diff actual.txt against fixture/expected.md. +# +# Exit codes: +# 0 = all fixtures passed (or --generate-golden succeeded) +# 1 = at least one fixture failed +# 2 = usage or environment error + +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +ADVISORY_SCRIPT="$SCRIPT_DIR/../../release-version-advisory.rb" +FIXTURES=(a-internal-removal b-public-addition c-public-body-change d-no-change e-public-signature-broken f-public-removal g-nodoc-removal h-public-api-txt) + +MODE="assert" +FIXTURE_ARG="" + +for arg in "$@"; do + case "$arg" in + --generate-golden) MODE="generate" ;; + -h|--help) sed -n '2,25p' "$0"; exit 0 ;; + *) FIXTURE_ARG="$arg" ;; + esac +done + +if [[ ! -f "$ADVISORY_SCRIPT" ]]; then + echo "cannot find advisory script at $ADVISORY_SCRIPT" >&2 + exit 2 +fi + +# Capture current output for a fixture into /tmp/actual-{name}.txt. +# Format: first line is the ::notice:: line; blank line; then step summary body. +capture_fixture_output() { + local fixture_name="$1" + local fixture_dir="$SCRIPT_DIR/$fixture_name" + local actual_file="$2" + + if [[ ! -f "$fixture_dir/before.rb" || ! -f "$fixture_dir/after.rb" ]]; then + echo "fixture $fixture_name missing before.rb or after.rb" >&2 + return 1 + fi + + local scratch summary_file stderr_file + scratch="$(mktemp -d "/tmp/advisory-fixture-$fixture_name-XXXXXX")" + summary_file="$(mktemp "/tmp/advisory-stepsummary-$fixture_name-XXXXXX")" + stderr_file="$(mktemp "/tmp/advisory-stderr-$fixture_name-XXXXXX")" + + # shellcheck disable=SC2064 + trap "rm -rf '$scratch' '$summary_file' '$stderr_file'" RETURN + + ( + cd "$scratch" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + mkdir -p lib + cp "$fixture_dir/before.rb" lib/foo.rb + # Optional contract file (public_api.txt) — present at both tags when supplied + if [[ -f "$fixture_dir/public_api.txt" ]]; then + cp "$fixture_dir/public_api.txt" public_api.txt + fi + git add . && git commit -q -m "before" + git tag v0.1.0 + cp "$fixture_dir/after.rb" lib/foo.rb + git add . && git commit -q -m "after" + + GITHUB_STEP_SUMMARY="$summary_file" \ + ADVISORY_REQUESTED_BUMP="patch" \ + ruby "$ADVISORY_SCRIPT" 2> "$stderr_file" + ) >/dev/null + + local notice_line + notice_line="$(grep -E '::notice title=Version advisory::' "$stderr_file" | head -1)" + { + echo "$notice_line" + echo + cat "$summary_file" + } > "$actual_file" +} + +pass=0 +fail=0 + +process_fixture() { + local fixture_name="$1" + local fixture_dir="$SCRIPT_DIR/$fixture_name" + local expected_file="$fixture_dir/expected.md" + local actual_file + actual_file="$(mktemp "/tmp/advisory-actual-$fixture_name-XXXXXX.md")" + + if ! capture_fixture_output "$fixture_name" "$actual_file"; then + echo " [ERROR] $fixture_name: could not capture output" + fail=$((fail + 1)) + rm -f "$actual_file" + return + fi + + if [[ "$MODE" == "generate" ]]; then + cp "$actual_file" "$expected_file" + echo " [WROTE] $fixture_name/expected.md ($(wc -l < "$expected_file") lines)" + rm -f "$actual_file" + pass=$((pass + 1)) + return + fi + + if [[ ! -f "$expected_file" ]]; then + echo " [MISSING GOLDEN] $fixture_name/expected.md" + echo " actual output at $actual_file" + echo " to bootstrap: cp $actual_file $expected_file" + fail=$((fail + 1)) + return + fi + + if diff -u "$expected_file" "$actual_file" >/dev/null; then + echo " [PASS] $fixture_name" + pass=$((pass + 1)) + rm -f "$actual_file" + else + echo " [FAIL] $fixture_name" + diff -u "$expected_file" "$actual_file" | sed 's/^/ /' + fail=$((fail + 1)) + rm -f "$actual_file" + fi +} + +if [[ -n "$FIXTURE_ARG" ]]; then + echo "Running fixture: $FIXTURE_ARG (mode=$MODE)" + process_fixture "$FIXTURE_ARG" +else + echo "Running all fixtures (mode=$MODE)" + for f in "${FIXTURES[@]}"; do + process_fixture "$f" + done +fi + +echo +echo "Summary: $pass passed, $fail failed" +exit $((fail > 0 ? 1 : 0)) diff --git a/.github/workflows/rubygems-release.yml b/.github/workflows/rubygems-release.yml index 5ac5184..c067a6c 100644 --- a/.github/workflows/rubygems-release.yml +++ b/.github/workflows/rubygems-release.yml @@ -78,6 +78,16 @@ on: required: false type: string default: 'auto' + version_advisory: + description: | + When true, run the release-version-advisory heuristic in preflight. + Emits a markdown table to $GITHUB_STEP_SUMMARY and a ::notice:: with + a suggested bump magnitude based on Prism AST diff of lib/. Advisory + only — never blocks the release. Default false (opt-in). + Design brief: https://github.com/metanorma/ci/issues/369 + required: false + type: boolean + default: false secrets: rubygems-api-key: required: false @@ -169,6 +179,14 @@ jobs: echo "ℹ️ Latest published: $latest" fi + # ============ Version advisory (opt-in, per ci#369) ============ + # Composite action — pin a tag, never curl from main. Encapsulated. + - name: Version advisory + if: inputs.version_advisory == true + uses: metanorma/ci/version-advisory-action@main + with: + requested-bump: ${{ inputs.next_version }} + release: # Runs after preflight when preflight ran (workflow_dispatch path); runs # directly when preflight was skipped (repository_dispatch / push paths). diff --git a/.github/workflows/test-version-advisory.yml b/.github/workflows/test-version-advisory.yml new file mode 100644 index 0000000..92246c5 --- /dev/null +++ b/.github/workflows/test-version-advisory.yml @@ -0,0 +1,56 @@ +name: test-version-advisory +# CI for the release-version-advisory.rb change detector (per ci#369). +# Runs the fixture suite on push/PR to the script or fixtures. Ruby 3.3 (Prism +# is bundled from 3.3). + +on: + push: + branches: [main] + paths: + - .github/scripts/release-version-advisory.rb + - .github/scripts/test-fixtures/version-advisory/** + - .github/workflows/test-version-advisory.yml + pull_request: + paths: + - .github/scripts/release-version-advisory.rb + - .github/scripts/test-fixtures/version-advisory/** + - .github/workflows/test-version-advisory.yml + +permissions: + contents: read + +jobs: + fixtures: + name: Fixture suite + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + + - name: Syntax check + run: ruby -c .github/scripts/release-version-advisory.rb + + - name: Run fixture suite + run: bash .github/scripts/test-fixtures/version-advisory/run-fixtures.sh + + no-network-libs: + name: AC 6 — no network libraries in advisory script + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Assert no network-lib requires + # AC 6 (per ci#369): no rubygems.org calls, no network. Enforce via + # static grep against known Ruby HTTP libraries. Prism + Digest + Open3 + # + JSON are all Ruby stdlib; anything else in a `require` is suspect. + run: | + set -e + script=.github/scripts/release-version-advisory.rb + if grep -Ei 'require\s+["'\''"](net/http|open-uri|faraday|httparty|excon|typhoeus|http)["'\''"]' "$script"; then + echo "::error title=AC 6 violation::advisory script requires a network library — see grep hit above" + exit 1 + fi + echo "OK — no network library requires in $script" diff --git a/docs/rubygems-release.md b/docs/rubygems-release.md index c8aceb5..33989a0 100644 --- a/docs/rubygems-release.md +++ b/docs/rubygems-release.md @@ -80,6 +80,8 @@ Version-bump semantics for this reusable follow SemVer at the reusable-interface | `role_to_assume` | no | — | OIDC Role ID (`rg_oidc_akr_…`) for RubyGems Trusted Publishing. If omitted with no API key, the workflow uses Trusted Publisher auto-discovery via `GITHUB_REPOSITORY`. | | `environment` | no | `''` | GitHub environment name (e.g. `release` for required approvers) | | `event_name` | no | — | Deprecated alias for `github.event_name`. | +| `release_notes` | no | `auto` | `auto` creates a GitHub Release with auto-generated notes if none exists; `manual` skips. See [ci#354](https://github.com/metanorma/ci/issues/354). | +| `version_advisory` | no | `false` | When true, run the Prism AST change-detector in preflight and emit a suggested bump. Advisory only — never blocks. See [ci#369](https://github.com/metanorma/ci/issues/369). | ## Secrets @@ -112,6 +114,7 @@ Motivated by [ci#309](https://github.com/metanorma/ci/issues/309): the `metanorm | **OIDC Trusted Publisher exchange** (only when no API key and no role) | Trust-policy mismatch on rubygems.org — runs the same `configure-rubygems-credentials@v2.1.0` action the publish step uses, just upfront | | **`bundle exec rake` resolves** (release job, API-key path only) | `rake` not installed because the Gemfile excludes the development group. See [ci#363](https://github.com/metanorma/ci/issues/363). | | **Version awareness** (informational, non-blocking) | Current gemspec version already on rubygems.org. For `next_version=skip` this means the publish will idempotent-skip. | +| **Version advisory** (opt-in, non-blocking) | Prism AST diff of `lib/` vs previous tag → suggested SemVer bump. Only when `version_advisory: true`. | Preflight cannot catch everything. It runs on `ubuntu-latest` only, doesn't run the actual test matrix, can't dry-run MFA/OTP prompts, and doesn't verify downstream-cascade receivers. @@ -148,6 +151,39 @@ Previous versions of this workflow had a `gated` mode that deferred publication The `gated` input remains as a deprecated no-op for backward compatibility. Consumer repos that still pass `gated: true` will see no behavior change beyond the publish now happening immediately. +## Version advisory (opt-in) + +When `version_advisory: true`, preflight runs the encapsulated composite action [`version-advisory-action`](../version-advisory-action/action.yml). It diffs `lib/` between the previous tag and HEAD via Prism AST and emits: + +- a markdown table to `$GITHUB_STEP_SUMMARY` (change / classification / evidence / suggested bump) +- a `::notice::` with the overall suggested bump + +Never blocks (exit 0 always). Default off — no behaviour change for existing consumers. + +### Classification signals (first-match-wins) + +1. Annotations: `# @api public` / `# @api private` / `:nodoc:` / Yard `@!method` +2. Contract file: `public_api.txt` allowlist at repo root (use-only-if-present) +3. Namespace convention: `Internal::` / `Private::` / leading `_` +4. Directory convention: `lib/*/internal/`, `lib/*/private/` +5. Default: `unknown` + +### Bump table + +| Change | public | internal | unknown | +|---|---|---|---| +| added | minor | patch | patch | +| removed / signature-broken | major (minor pre-1.0) | patch | unknown | +| body-changed | unknown | patch | unknown | + +### Phase-2 limitations + +`class << self`, `attr_*`, inheritance/include changes, `define_method` metaprogramming — not covered. Documented in the script header. + +### Encapsulation + +The heuristic lives in a composite action. Consumers of `rubygems-release.yml` never curl a raw script from main. Pin the reusable (and thereby the action) via the three-tier tag discipline. + ## Related - [`./monorepo-rubygems-release.md`](./monorepo-rubygems-release.md) — monorepo variant diff --git a/version-advisory-action/action.yml b/version-advisory-action/action.yml new file mode 100644 index 0000000..92099b7 --- /dev/null +++ b/version-advisory-action/action.yml @@ -0,0 +1,32 @@ +name: version-advisory +description: > + Advisory-only release-version heuristic (ci#369). Runs Prism AST diff of lib/ + against the previous tag and emits a markdown table + ::notice:: with a + suggested bump. Never blocks. Encapsulated composite — consumers pin a tag + (e.g. @v1), never curl from main. + +inputs: + requested-bump: + description: The bump the maintainer selected (patch/minor/major/skip) + required: false + default: '' + fetch-history: + description: When true, fetch tags + unshallow before running + required: false + default: 'true' + +runs: + using: composite + steps: + - name: Fetch git history for advisory + if: inputs.fetch-history == 'true' + shell: bash + run: | + git fetch --tags origin || true + git fetch --unshallow 2>/dev/null || true + + - name: Version advisory + shell: bash + env: + ADVISORY_REQUESTED_BUMP: ${{ inputs.requested-bump }} + run: ruby "${{ github.action_path }}/../.github/scripts/release-version-advisory.rb"