ruby-gem-release 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +23 -18
  4. data/lib/ruby-gem-release/changelog/base.rb +24 -0
  5. data/lib/ruby-gem-release/changelog/generated.rb +48 -0
  6. data/lib/ruby-gem-release/changelog/intridea.rb +16 -2
  7. data/lib/ruby-gem-release/changelog/keep_a_changelog.rb +14 -3
  8. data/lib/ruby-gem-release/changelog.rb +1 -0
  9. data/lib/ruby-gem-release/destination/ruby_gems.rb +69 -0
  10. data/lib/ruby-gem-release/destination.rb +19 -0
  11. data/lib/ruby-gem-release/execution_context/github/run.rb +31 -0
  12. data/lib/ruby-gem-release/execution_context/github/step.rb +28 -0
  13. data/lib/ruby-gem-release/execution_context/github.rb +33 -0
  14. data/lib/ruby-gem-release/execution_context/step.rb +16 -0
  15. data/lib/ruby-gem-release/execution_context.rb +32 -0
  16. data/lib/ruby-gem-release/execution_provider/github.rb +81 -0
  17. data/lib/ruby-gem-release/execution_provider.rb +65 -0
  18. data/lib/ruby-gem-release/github_api.rb +35 -0
  19. data/lib/ruby-gem-release/hosting/github_release/existing.rb +64 -0
  20. data/lib/ruby-gem-release/hosting/github_release.rb +52 -6
  21. data/lib/ruby-gem-release/package/ruby_gem.rb +22 -37
  22. data/lib/ruby-gem-release/release.rb +15 -9
  23. data/lib/ruby-gem-release/release_failure.rb +36 -0
  24. data/lib/ruby-gem-release/semantic_release/conventional_commits.rb +102 -0
  25. data/lib/ruby-gem-release/semantic_release/decision.rb +111 -0
  26. data/lib/ruby-gem-release/semantic_release/git_history.rb +43 -0
  27. data/lib/ruby-gem-release/semantic_release/release_notes.rb +40 -0
  28. data/lib/ruby-gem-release/semantic_release/version_selector.rb +48 -0
  29. data/lib/ruby-gem-release/semantic_release.rb +28 -0
  30. data/lib/ruby-gem-release/source_control/git/preparation.rb +1 -1
  31. data/lib/ruby-gem-release/source_control/git.rb +117 -21
  32. data/lib/ruby-gem-release/tasks.rb +190 -32
  33. data/lib/ruby-gem-release/version/ruby_constant.rb +7 -3
  34. data/lib/ruby-gem-release/version.rb +1 -1
  35. data/lib/ruby-gem-release/workflow/environment.rb +110 -0
  36. data/lib/ruby-gem-release/workflow/failure.rb +47 -0
  37. data/lib/ruby-gem-release/workflow/ruby_gem.rb +57 -13
  38. data/lib/ruby-gem-release/workflow/ruby_gems_credentials.rb +13 -26
  39. data/lib/ruby-gem-release/workflow/semantic_ruby_gem.rb +64 -0
  40. data/lib/ruby-gem-release/workflow/state.rb +94 -0
  41. metadata +25 -3
  42. data/lib/ruby-gem-release/workflow/context.rb +0 -96
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'execution_context'
4
+
5
+ module RubyGemRelease
6
+ class ExecutionProvider
7
+ def self.current(environment: ENV)
8
+ return new(environment: environment) unless environment['GITHUB_ACTIONS'] == 'true'
9
+
10
+ require_relative 'execution_provider/github'
11
+ GitHub.new(environment: environment)
12
+ end
13
+
14
+ def initialize(environment: ENV)
15
+ @environment = environment
16
+ end
17
+
18
+ def fetch(name, *defaults)
19
+ @environment.fetch(name, *defaults)
20
+ end
21
+
22
+ def directory
23
+ value = fetch('RELEASE_DATA_DIR', nil)
24
+ value unless value.to_s.empty?
25
+ end
26
+
27
+ def ref
28
+ fetch('SOURCE_REF')
29
+ end
30
+
31
+ def git_identity
32
+ { name: fetch('GIT_USER_NAME', 'ruby-gem-release'), email: fetch('GIT_USER_EMAIL', 'ruby-gem-release@localhost') }
33
+ end
34
+
35
+ def export(values)
36
+ validate_values(values)
37
+ @environment.update(values)
38
+ end
39
+
40
+ def context(**attributes)
41
+ ExecutionContext.new(**attributes)
42
+ end
43
+
44
+ def step_id(task)
45
+ ExecutionContext.step_id(task)
46
+ end
47
+
48
+ def failed_steps
49
+ []
50
+ end
51
+
52
+ def oidc_token(audience:)
53
+ raise NotImplementedError, 'execution provider does not support OIDC'
54
+ end
55
+
56
+ def mask(_value); end
57
+
58
+ private
59
+
60
+ def validate_values(values)
61
+ raise ArgumentError, 'Workflow names must be identifiers' unless values.keys.all? { |name| name.is_a?(String) && /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name) }
62
+ raise ArgumentError, 'Workflow values cannot contain line breaks' if values.values.any? { |value| value.match?(/[\r\n]/) }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ module RubyGemRelease
7
+ class GitHubAPI
8
+ def initialize(repository: nil)
9
+ @repository = repository || '{owner}/{repo}'
10
+ end
11
+
12
+ def get(path)
13
+ output, error, status = Open3.capture3('gh', 'api', '--include', endpoint(path))
14
+ headers, body = output.split(/\r?\n\r?\n/, 2)
15
+ code = headers.to_s[%r{\AHTTP/[\d.]+ (\d{3})}, 1]
16
+ return if code == '404'
17
+ raise "GitHub API request failed (HTTP #{code || 'unknown'}): #{error.strip}" unless status.success? && code == '200'
18
+
19
+ JSON.parse(body)
20
+ end
21
+
22
+ def download(path)
23
+ output, error, status = Open3.capture3('gh', 'api', endpoint(path), '--header', 'Accept: application/octet-stream')
24
+ raise "GitHub asset download failed: #{error.strip}" unless status.success?
25
+
26
+ output
27
+ end
28
+
29
+ private
30
+
31
+ def endpoint(path)
32
+ "repos/#{@repository}/#{path}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative '../../github_api'
5
+
6
+ module RubyGemRelease
7
+ module Hosting
8
+ class GitHubRelease
9
+ class Existing
10
+ def initialize(repository:)
11
+ @api = GitHubAPI.new(repository: repository)
12
+ end
13
+
14
+ def missing(release, paths)
15
+ @attributes = @api.get("releases/tags/v#{release.version}")
16
+ return paths unless @attributes
17
+
18
+ validate_release(release)
19
+ paths.reject { |path| matching_asset?(path) }
20
+ end
21
+
22
+ def exists?
23
+ !@attributes.nil?
24
+ end
25
+
26
+ def url
27
+ @attributes.fetch('html_url')
28
+ end
29
+
30
+ def changed?(release)
31
+ expected = { 'name' => "v#{release.version}", 'draft' => false, 'prerelease' => false }
32
+ expected['body'] = release.notes if release.notes
33
+ expected.any? { |key, value| @attributes[key] != value }
34
+ end
35
+
36
+ private
37
+
38
+ def validate_release(release)
39
+ tag = "v#{release.version}"
40
+ valid = @attributes.is_a?(Hash) && @attributes['tag_name'] == tag && @attributes['assets'].is_a?(Array)
41
+ raise 'Existing GitHub Release conflicts with the intended release' unless valid
42
+ end
43
+
44
+ def matching_asset?(path)
45
+ matches = @attributes.fetch('assets').select { |asset| asset['name'] == File.basename(path) }
46
+ raise 'Existing GitHub Release has duplicate asset names' if matches.size > 1
47
+ return false if matches.empty?
48
+
49
+ asset_digest(matches.first) == Digest::SHA256.file(path).hexdigest
50
+ end
51
+
52
+ def asset_digest(asset)
53
+ digest = asset['digest']
54
+ return digest.delete_prefix('sha256:') if digest.is_a?(String) && /\Asha256:[0-9a-f]{64}\z/.match?(digest)
55
+
56
+ id = asset['id']
57
+ raise 'Existing GitHub asset cannot be verified' unless id.is_a?(Integer) && id.positive?
58
+
59
+ Digest::SHA256.hexdigest(@api.download("releases/assets/#{id}"))
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require 'tempfile'
5
+ require_relative 'github_release/existing'
4
6
 
5
7
  module RubyGemRelease
6
8
  module Hosting
@@ -13,12 +15,13 @@ module RubyGemRelease
13
15
  end
14
16
 
15
17
  def publish!(release, package: nil, artifacts: [])
16
- version = release.version
17
- raise ArgumentError, 'release version must be MAJOR.MINOR.PATCH' unless version.is_a?(String) && /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match?(version)
18
+ paths = release_paths(release, package, artifacts)
19
+ existing = Existing.new(repository: @repository)
20
+ missing = existing.missing(release, paths)
21
+ return upload_missing(release, existing, missing) if existing.exists?
18
22
 
19
- tag = "v#{version}"
20
- paths = artifact_paths(package ? [package.artifact(release)] : artifacts)
21
- output, error, status = Open3.capture3(*command(tag), *paths)
23
+ tag = "v#{release.version}"
24
+ output, error, status = create_release(tag, paths, release.notes)
22
25
  raise "GitHub Release creation failed: #{error.strip}" unless status.success?
23
26
 
24
27
  output.strip
@@ -26,10 +29,53 @@ module RubyGemRelease
26
29
 
27
30
  private
28
31
 
32
+ def release_paths(release, package, artifacts)
33
+ version = release.version
34
+ raise ArgumentError, 'release version must be MAJOR.MINOR.PATCH' unless version.is_a?(String) && /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match?(version)
35
+ raise ArgumentError, 'release notes must not be empty' if release.notes && release.notes.strip.empty?
36
+
37
+ artifact_paths(package ? [package.artifact(release)] : artifacts)
38
+ end
39
+
40
+ def upload_missing(release, existing, paths)
41
+ edit_release(release) if existing.changed?(release)
42
+ unless paths.empty?
43
+ command = ['gh', 'release', 'upload', "v#{release.version}", '--clobber']
44
+ command.push('--repo', @repository) if @repository
45
+ _output, error, status = Open3.capture3(*command, *paths)
46
+ raise "GitHub asset upload failed: #{error.strip}" unless status.success?
47
+ end
48
+ existing.url
49
+ end
50
+
51
+ def create_release(tag, paths, notes)
52
+ arguments = command(tag)
53
+ arguments << '--generate-notes' if notes.nil?
54
+ run_with_notes(arguments, notes, paths)
55
+ end
56
+
57
+ def run_with_notes(arguments, notes, paths = [])
58
+ return Open3.capture3(*arguments, *paths) if notes.nil?
59
+
60
+ Tempfile.create(['ruby-gem-release-notes-', '.md']) do |file|
61
+ file.write(notes)
62
+ file.close
63
+ Open3.capture3(*arguments, '--notes-file', file.path, *paths)
64
+ end
65
+ end
66
+
67
+ def edit_release(release)
68
+ tag = "v#{release.version}"
69
+ arguments = ['gh', 'release', 'edit', tag, '--title', tag, '--draft=false', '--prerelease=false']
70
+ arguments.push('--repo', @repository) if @repository
71
+ _output, error, status = run_with_notes(arguments, release.notes)
72
+ raise "GitHub Release update failed: #{error.strip}" unless status.success?
73
+ end
74
+
29
75
  def command(tag)
30
76
  arguments = ['gh', 'release', 'create', tag]
31
77
  arguments.push('--repo', @repository) if @repository
32
- arguments.push('--verify-tag', '--title', tag, '--generate-notes')
78
+ arguments.push('--verify-tag', '--title', tag)
33
79
  end
34
80
 
35
81
  def artifact_paths(artifacts)
@@ -6,8 +6,6 @@ require 'json'
6
6
  require 'digest'
7
7
  require 'fileutils'
8
8
  require 'tmpdir'
9
- require 'net/http'
10
- require 'uri'
11
9
 
12
10
  module RubyGemRelease
13
11
  module Package
@@ -15,29 +13,31 @@ module RubyGemRelease
15
13
  attr_reader :directory, :gemspec
16
14
 
17
15
  def initialize(gemspec: nil, directory: nil)
18
- @gemspec = resolve_gemspec(gemspec || ENV.fetch('GEMSPEC', nil))
19
- @directory = directory || ENV['RUNNER_TEMP'] || Dir.mktmpdir('ruby-gem-release-')
16
+ @gemspec = resolve_gemspec(gemspec || ENV.fetch('GEMSPEC_PATH', nil))
17
+ directory ||= ENV.fetch('RELEASE_DATA_DIR', nil)
18
+ @directory = directory.to_s.empty? ? Dir.mktmpdir('ruby-gem-release-') : directory
20
19
  end
21
20
 
22
21
  def version
23
- specification.fetch('version')
24
- end
25
-
26
- def check_version!(version)
27
- name = specification.fetch('name')
28
- raise 'Invalid package name' unless name.is_a?(String) && /\A[A-Za-z0-9][A-Za-z0-9_.-]*\z/.match?(name)
29
-
30
- check_published_version(name, version)
22
+ script = 'spec = Gem::Specification.load(ARGV.fetch(0)); abort "Cannot load gemspec" unless spec; puts JSON.generate(spec.version.to_s)'
23
+ JSON.parse(run(Gem.ruby, '-rrubygems', '-rjson', '-e', script, @gemspec))
31
24
  end
32
25
 
33
26
  def build!(release)
34
27
  FileUtils.mkdir_p(@directory)
35
- run(Gem.ruby, '-S', 'gem', 'build', @gemspec, '--output', archive)
28
+ run({ 'SOURCE_DATE_EPOCH' => source_date_epoch }, Gem.ruby, '-S', 'gem', 'build', @gemspec, '--output', archive)
36
29
  @manifest = build_manifest(release)
37
30
  File.write(manifest_path, JSON.generate(@manifest))
38
31
  artifact(release)
39
32
  end
40
33
 
34
+ def built?(release)
35
+ return false unless File.exist?(archive) || File.exist?(manifest_path)
36
+
37
+ verified_manifest(release)
38
+ true
39
+ end
40
+
41
41
  def artifact(release)
42
42
  name = verified_manifest(release).fetch('name')
43
43
  raise 'Invalid package name' unless /\A[A-Za-z0-9][A-Za-z0-9_.-]*\z/.match?(name)
@@ -47,32 +47,11 @@ module RubyGemRelease
47
47
  path
48
48
  end
49
49
 
50
- def publish!(release)
51
- path = artifact(release)
52
- check_published_version(verified_manifest(release).fetch('name'), release.version)
53
- run(Gem.ruby, '-S', 'gem', 'push', path)
54
- end
55
-
56
50
  private
57
51
 
58
- def specification
59
- script = 'spec = Gem::Specification.load(ARGV.fetch(0)); abort "Cannot load gemspec" unless spec; puts JSON.generate(name: spec.name, version: spec.version.to_s)'
60
- JSON.parse(run(Gem.ruby, '-rrubygems', '-rjson', '-e', script, @gemspec))
61
- end
62
-
63
- def check_published_version(name, version)
64
- response = version_response(name, version)
65
- raise "#{name} #{version} is already published on RubyGems; do not republish it, see RELEASING.md for recovery" if response.code == '200'
66
- return if response.code == '404'
67
-
68
- raise "Cannot check RubyGems for #{name} #{version} (HTTP #{response.code}); verify RubyGems availability before retrying"
69
- end
70
-
71
- def version_response(name, version)
72
- uri = URI("https://rubygems.org/api/v2/rubygems/#{URI.encode_www_form_component(name)}/versions/#{URI.encode_www_form_component(version)}.json")
73
- Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30, max_retries: 0) { |http| http.get(uri.request_uri) }
74
- rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
75
- raise "Cannot check RubyGems for #{name} #{version}: #{e.message}; verify connectivity before retrying"
52
+ def source_date_epoch
53
+ value = ENV.fetch('SOURCE_DATE_EPOCH', '').strip
54
+ value.empty? ? '315619200' : value
76
55
  end
77
56
 
78
57
  def build_manifest(release)
@@ -87,9 +66,15 @@ module RubyGemRelease
87
66
  raise 'Package version does not match release' unless manifest.fetch('version') == release.version
88
67
  raise 'Built artifact changed; refusing publication' unless Digest::SHA256.file(archive).hexdigest == manifest.fetch('sha256')
89
68
 
69
+ verify_metadata!(manifest, release)
90
70
  manifest
91
71
  end
92
72
 
73
+ def verify_metadata!(manifest, release)
74
+ spec = Gem::Package.new(archive).spec
75
+ raise 'Artifact metadata differs from manifest' unless spec.name == manifest.fetch('name') && spec.version.to_s == release.version
76
+ end
77
+
93
78
  def archive
94
79
  File.join(@directory, 'release.gem')
95
80
  end
@@ -4,31 +4,37 @@ require 'date'
4
4
 
5
5
  module RubyGemRelease
6
6
  class Release
7
- attr_reader :version, :previous_version, :next_version, :date
7
+ attr_reader :version, :gemspec_version, :next_version, :date, :notes
8
8
 
9
- def initialize(date:, version:, previous_version:, next_version: nil)
10
- validate_versions(version, previous_version, next_version)
11
- raise ArgumentError, 'date must be a Date' unless date.instance_of?(Date)
9
+ def initialize(date:, version:, gemspec_version:, next_version: nil, notes: nil)
10
+ validate_versions(version, gemspec_version, next_version)
11
+ validate_metadata(date, notes)
12
12
 
13
13
  @version = version.dup.freeze
14
- @previous_version = previous_version.dup.freeze
14
+ @gemspec_version = gemspec_version.dup.freeze
15
15
  @next_version = next_version&.dup&.freeze
16
16
  @date = date.dup.freeze
17
+ @notes = notes&.dup&.freeze
17
18
  freeze
18
19
  end
19
20
 
20
21
  def with_version(version)
21
- self.class.new(version: version, previous_version: @previous_version, date: @date, next_version: @next_version)
22
+ self.class.new(version: version, gemspec_version: @gemspec_version, date: @date, next_version: @next_version, notes: @notes)
22
23
  end
23
24
 
24
25
  def with_next_version(version)
25
- self.class.new(version: @version, previous_version: @previous_version, date: @date, next_version: version)
26
+ self.class.new(version: @version, gemspec_version: @gemspec_version, date: @date, next_version: version, notes: @notes)
26
27
  end
27
28
 
28
29
  private
29
30
 
30
- def validate_versions(version, previous_version, next_version)
31
- raise ArgumentError, 'release versions must be strings' unless [version, previous_version].all?(String) && (next_version.nil? || next_version.is_a?(String))
31
+ def validate_metadata(date, notes)
32
+ raise ArgumentError, 'date must be a Date' unless date.instance_of?(Date)
33
+ raise ArgumentError, 'release notes must be a string or nil' unless notes.nil? || notes.is_a?(String)
34
+ end
35
+
36
+ def validate_versions(version, gemspec_version, next_version)
37
+ raise ArgumentError, 'release versions must be strings' unless [version, gemspec_version].all?(String) && (next_version.nil? || next_version.is_a?(String))
32
38
  end
33
39
  end
34
40
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyGemRelease
4
+ class ReleaseFailure
5
+ class CapturedError < StandardError
6
+ attr_reader :original_class
7
+
8
+ def self.capture(error)
9
+ new(original_class: error.class.name || error.class.to_s, message: error.message, backtrace: error.backtrace)
10
+ end
11
+
12
+ def self.from_h(attributes)
13
+ new(original_class: attributes.fetch('class'), message: attributes.fetch('message'), backtrace: attributes.fetch('backtrace'))
14
+ end
15
+
16
+ def initialize(original_class:, message:, backtrace:)
17
+ @original_class = original_class.freeze
18
+ super(message)
19
+ set_backtrace(backtrace)
20
+ end
21
+
22
+ def to_h
23
+ { 'class' => original_class, 'message' => message, 'backtrace' => backtrace }
24
+ end
25
+ end
26
+
27
+ attr_reader :phase, :error, :context
28
+
29
+ def initialize(phase:, error:, context:)
30
+ @phase = phase.dup.freeze
31
+ @error = error
32
+ @context = context
33
+ freeze
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'decision'
4
+
5
+ module RubyGemRelease
6
+ class SemanticRelease
7
+ class ConventionalCommits
8
+ SELECTORS = %i[type scope subject breaking revert].freeze
9
+
10
+ def initialize(rules: [])
11
+ @rules = rules.map do |rule|
12
+ validate_rule(rule)
13
+ rule.to_h { |key, value| [key, value.is_a?(String) ? value.dup.freeze : value] }.freeze
14
+ end.freeze
15
+ end
16
+
17
+ def analyze(commits)
18
+ parsed(commits).filter_map { |commit| release_type(commit) }.max_by { |type| Decision::TYPES.index(type) }
19
+ end
20
+
21
+ def parsed(commits)
22
+ parsed = commits.reject { |commit| commit.fetch('message').strip.empty? }.map { |commit| parse(commit) }
23
+ cancelled = []
24
+ parsed.reverse_each do |commit|
25
+ next if cancelled.include?(commit) || !commit[:revert]
26
+
27
+ original = reverted_original(parsed, commit, cancelled)
28
+ cancelled.push(commit, original) if original
29
+ end
30
+ parsed - cancelled
31
+ end
32
+
33
+ private
34
+
35
+ def reverted_original(commits, revert, cancelled)
36
+ commits.find { |commit| commit != revert && commit[:sha].start_with?(revert[:revert]) && !cancelled.include?(commit) }
37
+ end
38
+
39
+ def parse(commit)
40
+ message = commit.fetch('message')
41
+ attributes = parsed_header(message.lines(chomp: true).first.to_s)
42
+ notes = breaking_notes(message)
43
+ notes << attributes[:subject] if attributes[:breaking] && notes.empty?
44
+ attributes.merge(sha: commit.fetch('sha'), notes: notes, breaking: attributes[:breaking] || !notes.empty?,
45
+ revert: message[/^This reverts commit ([0-9a-f]{7,64})\./i, 1])
46
+ end
47
+
48
+ def parsed_header(header)
49
+ match = /\A([A-Za-z0-9_-]+)(?:\(([^()\r\n]+)\))?(!)?: (.+)\z/.match(header)
50
+ return { type: nil, scope: nil, subject: header, breaking: false } unless match
51
+
52
+ { type: match[1], scope: match[2], subject: match[4], breaking: match[3] == '!' }
53
+ end
54
+
55
+ def breaking_notes(message)
56
+ message.split(/^(?=BREAKING(?: CHANGE|-CHANGE):|[A-Za-z][A-Za-z-]*(?:: | #))/).filter_map do |section|
57
+ match = /\ABREAKING(?: CHANGE|-CHANGE):[ \t]*(.*)/m.match(section)
58
+ match[1].strip if match
59
+ end
60
+ end
61
+
62
+ def release_type(commit)
63
+ matches = @rules.select { |rule| matches?(commit, rule) }
64
+ return default_type(commit) if matches.empty?
65
+
66
+ matches.filter_map { |rule| rule[:release].to_s unless rule[:release] == false }.max_by { |type| Decision::TYPES.index(type) }
67
+ end
68
+
69
+ def default_type(commit)
70
+ return 'major' if commit[:breaking]
71
+ return 'minor' if commit[:type] == 'feat'
72
+ return 'patch' if commit[:revert] || %w[fix perf].include?(commit[:type])
73
+
74
+ nil
75
+ end
76
+
77
+ def matches?(commit, rule)
78
+ rule.all? do |key, value|
79
+ next true if key == :release
80
+ next value ? commit[key] : !commit[key] if %i[breaking revert].include?(key)
81
+
82
+ commit[key].is_a?(String) && File.fnmatch?(value, commit[key])
83
+ end
84
+ end
85
+
86
+ def validate_rule(rule)
87
+ valid = rule.is_a?(Hash) && (rule.keys - [*SELECTORS, :release]).empty? && rule.key?(:release) &&
88
+ (rule[:release] == false || Decision::TYPES.include?(rule[:release].to_s))
89
+ raise ArgumentError, 'release rules require supported selectors and major, minor, patch, or false' unless valid
90
+
91
+ validate_selectors(rule.except(:release))
92
+ end
93
+
94
+ def validate_selectors(selectors)
95
+ selectors.each do |key, value|
96
+ valid = %i[breaking revert].include?(key) ? [true, false].include?(value) : value.is_a?(String)
97
+ raise ArgumentError, "invalid release rule selector: #{key}" unless valid
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../version/ruby_constant'
4
+ require 'rubygems/version'
5
+
6
+ module RubyGemRelease
7
+ class SemanticRelease
8
+ class Decision
9
+ ATTRIBUTES = %w[head last_release commits release_type version notes explicit_version].freeze
10
+ TYPES = %w[patch minor major].freeze
11
+ SHA = /\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/
12
+
13
+ attr_reader(*ATTRIBUTES)
14
+
15
+ def initialize(head:, last_release:, commits:, release_type:, version:, notes:, explicit_version: nil)
16
+ validate_history(head, last_release, commits)
17
+ validate_selection(last_release, release_type, version, notes, explicit_version)
18
+ values = { 'head' => head, 'last_release' => last_release, 'commits' => commits, 'release_type' => release_type,
19
+ 'version' => version, 'notes' => notes, 'explicit_version' => explicit_version }
20
+ @attributes = immutable_copy(values)
21
+ @attributes.each { |key, value| instance_variable_set("@#{key}", value) }
22
+ freeze
23
+ end
24
+
25
+ def self.from_h(attributes)
26
+ raise ArgumentError, 'unsupported semantic decision' unless attributes.is_a?(Hash) && attributes.keys.sort == ATTRIBUTES.sort
27
+
28
+ new(**attributes.transform_keys(&:to_sym))
29
+ end
30
+
31
+ def to_h
32
+ @attributes
33
+ end
34
+
35
+ def release?
36
+ !@version.nil?
37
+ end
38
+
39
+ def check!(head:, explicit_version:, version: nil)
40
+ explicit_version = nil if explicit_version == ''
41
+ raise ArgumentError, 'semantic decision belongs to another commit' unless @head == head
42
+ raise ArgumentError, 'explicit version changed after semantic analysis' unless @explicit_version == explicit_version
43
+ raise ArgumentError, 'release version differs from semantic decision' if version && @version != version
44
+ end
45
+
46
+ private
47
+
48
+ def validate_history(head, last_release, commits)
49
+ raise ArgumentError, 'invalid semantic history head' unless valid_sha?(head)
50
+ raise ArgumentError, 'invalid last semantic release' unless last_release.nil? || valid_last_release?(last_release)
51
+
52
+ validate_commits(commits)
53
+ end
54
+
55
+ def valid_last_release?(release)
56
+ release.is_a?(Hash) && release.keys.sort == %w[commit tag version] &&
57
+ valid_version?(release['version']) && release['tag'] == "v#{release['version']}" && valid_sha?(release['commit'])
58
+ end
59
+
60
+ def validate_commits(commits)
61
+ valid = commits.is_a?(Array) && commits.all? { |commit| valid_commit?(commit) }
62
+ raise ArgumentError, 'invalid semantic commits' unless valid && commits.map { |commit| commit['sha'] }.uniq.size == commits.size
63
+ end
64
+
65
+ def valid_commit?(commit)
66
+ commit.is_a?(Hash) && commit.keys.sort == %w[message sha] && valid_sha?(commit['sha']) && commit['message'].is_a?(String)
67
+ end
68
+
69
+ def validate_selection(last_release, type, version, notes, explicit)
70
+ raise ArgumentError, 'invalid semantic release type' unless type.nil? || TYPES.include?(type)
71
+
72
+ if version.nil?
73
+ validate_skipped_selection(type, notes, explicit)
74
+
75
+ return
76
+ end
77
+ validate_selected_version(type, version, notes, explicit)
78
+ return unless last_release && Gem::Version.new(version) <= Gem::Version.new(last_release.fetch('version'))
79
+
80
+ raise ArgumentError, 'semantic version must exceed the last release'
81
+ end
82
+
83
+ def validate_selected_version(type, version, notes, explicit)
84
+ valid = valid_version?(version) && notes.is_a?(String) && !notes.strip.empty? && [type, explicit].any? &&
85
+ (explicit.nil? || explicit == version)
86
+ raise ArgumentError, 'invalid semantic selection' unless valid
87
+ end
88
+
89
+ def validate_skipped_selection(type, notes, explicit)
90
+ raise ArgumentError, 'invalid skipped semantic decision' unless [type, notes, explicit].all?(&:nil?)
91
+ end
92
+
93
+ def valid_sha?(value)
94
+ value.is_a?(String) && SHA.match?(value)
95
+ end
96
+
97
+ def valid_version?(value)
98
+ value.is_a?(String) && Version::RubyConstant::PATTERN.match?(value)
99
+ end
100
+
101
+ def immutable_copy(value)
102
+ case value
103
+ when Hash then value.to_h { |key, entry| [key.dup.freeze, immutable_copy(entry)] }.freeze
104
+ when Array then value.map { |entry| immutable_copy(entry) }.freeze
105
+ when String then value.dup.freeze
106
+ else value
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end