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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +23 -18
- data/lib/ruby-gem-release/changelog/base.rb +24 -0
- data/lib/ruby-gem-release/changelog/generated.rb +48 -0
- data/lib/ruby-gem-release/changelog/intridea.rb +16 -2
- data/lib/ruby-gem-release/changelog/keep_a_changelog.rb +14 -3
- data/lib/ruby-gem-release/changelog.rb +1 -0
- data/lib/ruby-gem-release/destination/ruby_gems.rb +69 -0
- data/lib/ruby-gem-release/destination.rb +19 -0
- data/lib/ruby-gem-release/execution_context/github/run.rb +31 -0
- data/lib/ruby-gem-release/execution_context/github/step.rb +28 -0
- data/lib/ruby-gem-release/execution_context/github.rb +33 -0
- data/lib/ruby-gem-release/execution_context/step.rb +16 -0
- data/lib/ruby-gem-release/execution_context.rb +32 -0
- data/lib/ruby-gem-release/execution_provider/github.rb +81 -0
- data/lib/ruby-gem-release/execution_provider.rb +65 -0
- data/lib/ruby-gem-release/github_api.rb +35 -0
- data/lib/ruby-gem-release/hosting/github_release/existing.rb +64 -0
- data/lib/ruby-gem-release/hosting/github_release.rb +52 -6
- data/lib/ruby-gem-release/package/ruby_gem.rb +22 -37
- data/lib/ruby-gem-release/release.rb +15 -9
- data/lib/ruby-gem-release/release_failure.rb +36 -0
- data/lib/ruby-gem-release/semantic_release/conventional_commits.rb +102 -0
- data/lib/ruby-gem-release/semantic_release/decision.rb +111 -0
- data/lib/ruby-gem-release/semantic_release/git_history.rb +43 -0
- data/lib/ruby-gem-release/semantic_release/release_notes.rb +40 -0
- data/lib/ruby-gem-release/semantic_release/version_selector.rb +48 -0
- data/lib/ruby-gem-release/semantic_release.rb +28 -0
- data/lib/ruby-gem-release/source_control/git/preparation.rb +1 -1
- data/lib/ruby-gem-release/source_control/git.rb +117 -21
- data/lib/ruby-gem-release/tasks.rb +190 -32
- data/lib/ruby-gem-release/version/ruby_constant.rb +7 -3
- data/lib/ruby-gem-release/version.rb +1 -1
- data/lib/ruby-gem-release/workflow/environment.rb +110 -0
- data/lib/ruby-gem-release/workflow/failure.rb +47 -0
- data/lib/ruby-gem-release/workflow/ruby_gem.rb +57 -13
- data/lib/ruby-gem-release/workflow/ruby_gems_credentials.rb +13 -26
- data/lib/ruby-gem-release/workflow/semantic_ruby_gem.rb +64 -0
- data/lib/ruby-gem-release/workflow/state.rb +94 -0
- metadata +25 -3
- 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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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('
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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, :
|
|
7
|
+
attr_reader :version, :gemspec_version, :next_version, :date, :notes
|
|
8
8
|
|
|
9
|
-
def initialize(date:, version:,
|
|
10
|
-
validate_versions(version,
|
|
11
|
-
|
|
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
|
-
@
|
|
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,
|
|
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,
|
|
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
|
|
31
|
-
raise ArgumentError, '
|
|
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
|