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,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../package/ruby_gem'
|
|
4
|
+
require_relative '../destination/ruby_gems'
|
|
5
|
+
require_relative '../source_control/git'
|
|
6
|
+
require_relative '../release'
|
|
7
|
+
require_relative '../version/ruby_constant'
|
|
8
|
+
require_relative 'state'
|
|
9
|
+
require_relative '../execution_provider'
|
|
10
|
+
|
|
11
|
+
module RubyGemRelease
|
|
12
|
+
module Workflow
|
|
13
|
+
class Environment
|
|
14
|
+
def initialize(provider: ExecutionProvider.current)
|
|
15
|
+
@provider = provider
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configure!
|
|
19
|
+
names = %w[INPUT_VERSION INPUT_GEMSPEC_PATH DEFAULT_BRANCH GIT_USER_NAME GIT_USER_EMAIL RUBYGEMS_OWNER_ID]
|
|
20
|
+
timezone = @provider.fetch('TZ', 'UTC')
|
|
21
|
+
validate_timezone(timezone)
|
|
22
|
+
@provider.export(names.to_h { |name| [name, @provider.fetch(name)] }.merge('TZ' => timezone))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def capture!(package: nil)
|
|
26
|
+
check_branch
|
|
27
|
+
validate_timezone(@provider.fetch('TZ', 'UTC'))
|
|
28
|
+
git = configured_git
|
|
29
|
+
git.check_clean!
|
|
30
|
+
package ||= Package::RubyGem.new(gemspec: @provider.fetch('INPUT_GEMSPEC_PATH', @provider.fetch('GEMSPEC_PATH', '')) || '', directory: @provider.directory)
|
|
31
|
+
release = initial_release(package)
|
|
32
|
+
git.check_current!(allow_ancestor: true)
|
|
33
|
+
@provider.export(captured_values(package, git, release))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resolve!(package: nil, destination: Destination::RubyGems.new, analysis: State.new(path: @provider.fetch('RELEASE_STATE_FILE', nil)).analysis)
|
|
37
|
+
package ||= Package::RubyGem.new(gemspec: @provider.fetch('GEMSPEC_PATH', @provider.fetch('INPUT_GEMSPEC_PATH', '')) || '', directory: @provider.directory)
|
|
38
|
+
authentication = authentication_for(destination)
|
|
39
|
+
version = prepared_version(package, analysis)
|
|
40
|
+
git = SourceControl::Git.new(directory: @provider.directory)
|
|
41
|
+
git.stage_changes!
|
|
42
|
+
@provider.export('RELEASE_VERSION' => version, 'RELEASE_AUTH_MODE' => authentication)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def authentication_for(destination)
|
|
48
|
+
authentication = destination.authentication
|
|
49
|
+
raise ArgumentError, 'unsupported destination authentication' unless [nil, 'rubygems'].include?(authentication)
|
|
50
|
+
|
|
51
|
+
authentication.to_s
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def captured_values(package, git, release)
|
|
55
|
+
commit = git.head
|
|
56
|
+
state = State.create!(directory: @provider.directory) if @provider.directory
|
|
57
|
+
{ 'TZ' => @provider.fetch('TZ', 'UTC'), 'GEMSPEC_PATH' => package.respond_to?(:gemspec) ? package.gemspec.to_s : '', 'GEMSPEC_VERSION' => release.version,
|
|
58
|
+
'RELEASE_DATA_DIR' => @provider.directory.to_s,
|
|
59
|
+
'RELEASE_DATE' => release.date.iso8601, 'SOURCE_COMMIT' => commit, 'RELEASE_STATE_FILE' => state.to_s,
|
|
60
|
+
'SOURCE_DATE_EPOCH' => source_date_epoch(git, commit) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def source_date_epoch(git, commit)
|
|
64
|
+
value = @provider.fetch('SOURCE_DATE_EPOCH', '').strip
|
|
65
|
+
value.empty? ? git.timestamp(commit) : value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prepared_version(package, analysis)
|
|
69
|
+
version = package.version
|
|
70
|
+
validate_version(version)
|
|
71
|
+
analysis&.check!(head: @provider.fetch('SOURCE_COMMIT'), explicit_version: @provider.fetch('INPUT_VERSION', ''), version: version)
|
|
72
|
+
selected = analysis ? analysis.version : @provider.fetch('INPUT_VERSION', '')
|
|
73
|
+
raise "Prepared version #{version} does not match selected version #{selected}" unless selected.empty? || version == selected
|
|
74
|
+
|
|
75
|
+
version
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initial_release(package)
|
|
79
|
+
version = package.version
|
|
80
|
+
validate_version(version)
|
|
81
|
+
validate_version(@provider.fetch('INPUT_VERSION')) unless @provider.fetch('INPUT_VERSION', '').empty?
|
|
82
|
+
Release.new(version: version, gemspec_version: version, date: release_date)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def release_date
|
|
86
|
+
(@provider.fetch('TZ', nil) ? Time.now.getlocal : Time.now.utc).to_date
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_timezone(timezone)
|
|
90
|
+
valid = %r{\A[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)*\z}.match?(timezone) &&
|
|
91
|
+
File.file?(File.join('/usr/share/zoneinfo', timezone))
|
|
92
|
+
raise ArgumentError, 'timezone must identify an installed timezone, such as UTC or America/New_York' unless valid
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def configured_git
|
|
96
|
+
SourceControl::Git.new(directory: @provider.directory).tap { |git| git.configure!(**@provider.git_identity) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def check_branch
|
|
100
|
+
return if @provider.ref == "refs/heads/#{@provider.fetch('DEFAULT_BRANCH')}"
|
|
101
|
+
|
|
102
|
+
raise 'Releases must run from the default branch'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_version(version)
|
|
106
|
+
raise ArgumentError, 'Version must be MAJOR.MINOR.PATCH without leading zeros' unless Version::RubyConstant::PATTERN.match?(version)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../release_failure'
|
|
5
|
+
require_relative '../execution_provider'
|
|
6
|
+
|
|
7
|
+
module RubyGemRelease
|
|
8
|
+
module Workflow
|
|
9
|
+
class Failure
|
|
10
|
+
class << self
|
|
11
|
+
def phase!(provider: ExecutionProvider.current)
|
|
12
|
+
failed = provider.failed_steps
|
|
13
|
+
phase = failed.find { |step| error_for(step, provider: provider) } || failed.first || 'unknown'
|
|
14
|
+
provider.export('RELEASE_FAILED_STEP_ID' => phase)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def capture!(phase, error, provider: ExecutionProvider.current)
|
|
18
|
+
return unless provider.directory
|
|
19
|
+
|
|
20
|
+
captured_error = ReleaseFailure::CapturedError.capture(error)
|
|
21
|
+
File.open(failure_file(provider), 'w', 0o600) do |file|
|
|
22
|
+
file.write(JSON.generate('phase' => phase, 'error' => captured_error.to_h))
|
|
23
|
+
end
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
warn "Unable to capture release error details: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def error_for(phase, provider: ExecutionProvider.current)
|
|
29
|
+
return unless provider.directory
|
|
30
|
+
|
|
31
|
+
failure = JSON.parse(File.read(failure_file(provider)))
|
|
32
|
+
return unless failure.fetch('phase') == phase
|
|
33
|
+
|
|
34
|
+
ReleaseFailure::CapturedError.from_h(failure.fetch('error'))
|
|
35
|
+
rescue Errno::ENOENT
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def failure_file(provider)
|
|
42
|
+
File.join(provider.directory, 'ruby-gem-release-failure.json')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -2,33 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../tasks'
|
|
4
4
|
require_relative '../package/ruby_gem'
|
|
5
|
+
require_relative '../destination/ruby_gems'
|
|
5
6
|
require_relative '../source_control/git'
|
|
6
7
|
require_relative '../hosting/github_release'
|
|
7
8
|
|
|
8
9
|
module RubyGemRelease
|
|
9
10
|
module Workflow
|
|
10
11
|
class RubyGem
|
|
11
|
-
|
|
12
|
+
attr_reader :version, :changelog, :package, :destination, :git, :hosting, :provider
|
|
13
|
+
|
|
14
|
+
def initialize(version:, changelog:, provider: ExecutionProvider.current,
|
|
15
|
+
package: Package::RubyGem.new(gemspec: provider.fetch('GEMSPEC_PATH', provider.fetch('INPUT_GEMSPEC_PATH', '')) || '', directory: provider.directory),
|
|
16
|
+
destination: Destination::RubyGems.new,
|
|
17
|
+
git: SourceControl::Git.new(directory: provider.directory), hosting: Hosting::GitHubRelease.new,
|
|
18
|
+
validate: nil, success: nil, failure: nil)
|
|
12
19
|
@version = version
|
|
13
20
|
@changelog = changelog
|
|
14
21
|
@package = package
|
|
22
|
+
@destination = destination
|
|
15
23
|
@git = git
|
|
16
24
|
@hosting = hosting
|
|
25
|
+
@provider = provider
|
|
26
|
+
@validate = validate
|
|
27
|
+
@success = success
|
|
28
|
+
@failure = failure
|
|
17
29
|
end
|
|
18
30
|
|
|
19
31
|
def install_tasks
|
|
20
|
-
Tasks.install do |tasks|
|
|
21
|
-
tasks
|
|
22
|
-
tasks.publish { |release| publish(release) }
|
|
23
|
-
tasks.after_release { |release| prepare_next(release) }
|
|
32
|
+
Tasks.install(workflow: self, package: @package, destination: @destination, provider: @provider) do |tasks|
|
|
33
|
+
install_callbacks(tasks)
|
|
24
34
|
end
|
|
25
35
|
end
|
|
26
36
|
|
|
37
|
+
def checkpoint!(context)
|
|
38
|
+
prepare_publication(context.release)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def published?(release)
|
|
42
|
+
@package.respond_to?(:built?) && @package.built?(release) && @destination.published?(release, package: @package)
|
|
43
|
+
end
|
|
44
|
+
|
|
27
45
|
private
|
|
28
46
|
|
|
47
|
+
def install_callbacks(tasks)
|
|
48
|
+
tasks.validate { |ctx| validate_release(ctx) }
|
|
49
|
+
tasks.prepare { |ctx| prepare_release(ctx.release) }
|
|
50
|
+
tasks.publish { |ctx| publish(ctx.release) }
|
|
51
|
+
tasks.bump { |ctx| prepare_next(ctx) }
|
|
52
|
+
tasks.success(&@success) if @success
|
|
53
|
+
tasks.failure(&@failure) if @failure
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_release(ctx)
|
|
57
|
+
return @validate.call(ctx) if @validate
|
|
58
|
+
|
|
59
|
+
return if @changelog.nil? || @changelog.released?(ctx.release.version) || @changelog.pending?
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, 'Nothing to release: no pending changelog notes'
|
|
62
|
+
end
|
|
63
|
+
|
|
29
64
|
def prepare_release(release)
|
|
30
65
|
@git.stage_changes! do
|
|
31
|
-
select_version!(release.version) unless
|
|
66
|
+
select_version!(release.version) unless @provider.fetch('INPUT_VERSION', '').empty?
|
|
32
67
|
@changelog&.release!(release.with_version(@version.read))
|
|
33
68
|
end
|
|
34
69
|
end
|
|
@@ -39,24 +74,33 @@ module RubyGemRelease
|
|
|
39
74
|
@version.set!(version)
|
|
40
75
|
end
|
|
41
76
|
|
|
42
|
-
def
|
|
43
|
-
@package.check_version!(release.version)
|
|
77
|
+
def prepare_publication(release)
|
|
44
78
|
@git.commit!(release)
|
|
45
79
|
@package.build!(release)
|
|
46
80
|
@git.tag!(release)
|
|
47
|
-
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def publish(release)
|
|
84
|
+
prepare_publication(release)
|
|
85
|
+
@destination.publish!(release, package: @package)
|
|
48
86
|
@git.push!(release)
|
|
49
87
|
@hosting&.publish!(release, package: @package)
|
|
50
88
|
end
|
|
51
89
|
|
|
52
|
-
def prepare_next(
|
|
90
|
+
def prepare_next(ctx)
|
|
91
|
+
next_release = stage_next!(ctx.release)
|
|
92
|
+
@git.commit_next!(next_release)
|
|
93
|
+
@git.push!
|
|
94
|
+
ctx.record_next_version!(next_release.next_version)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stage_next!(release)
|
|
53
98
|
next_release = nil
|
|
54
99
|
@git.stage_changes! do
|
|
55
|
-
next_release = release.with_next_version(@version.increment!)
|
|
100
|
+
next_release = release.with_next_version(@version.increment!(from: release.version))
|
|
56
101
|
@changelog&.prepare_next!(next_release)
|
|
57
102
|
end
|
|
58
|
-
|
|
59
|
-
@git.push!
|
|
103
|
+
next_release
|
|
60
104
|
end
|
|
61
105
|
end
|
|
62
106
|
end
|
|
@@ -3,45 +3,36 @@
|
|
|
3
3
|
require 'net/http'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'uri'
|
|
6
|
+
require_relative '../execution_provider'
|
|
6
7
|
|
|
7
8
|
module RubyGemRelease
|
|
8
9
|
module Workflow
|
|
9
10
|
class RubyGemsCredentials
|
|
10
|
-
def initialize(owner:)
|
|
11
|
-
raise ArgumentError, 'RubyGems owner must be a positive numeric account ID' unless /\A[1-9]\d*\z/.match?(owner.to_s)
|
|
12
|
-
|
|
11
|
+
def initialize(owner: nil, provider: ExecutionProvider.current)
|
|
13
12
|
@owner = owner.to_s
|
|
13
|
+
raise ArgumentError, 'RubyGems owner must be a positive numeric account ID' unless @owner.empty? || /\A[1-9]\d*\z/.match?(@owner)
|
|
14
|
+
|
|
15
|
+
@provider = provider
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def configure!
|
|
17
|
-
token = oidc_token
|
|
18
|
-
mask(token)
|
|
19
|
+
token = @provider.oidc_token(audience: 'rubygems.org')
|
|
20
|
+
@provider.mask(token)
|
|
19
21
|
key = exchange_token(token)
|
|
20
|
-
mask(key)
|
|
21
|
-
|
|
22
|
-
%w[RUBYGEMS_API_KEY BUNDLE_GEM__PUSH_KEY GEM_HOST_API_KEY].each { |name| file.puts "#{name}=#{key}" }
|
|
23
|
-
end
|
|
22
|
+
@provider.mask(key)
|
|
23
|
+
@provider.export(%w[RUBYGEMS_API_KEY BUNDLE_GEM__PUSH_KEY GEM_HOST_API_KEY].to_h { |name| [name, key] })
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
-
def oidc_token
|
|
29
|
-
uri = URI(ENV.fetch('ACTIONS_ID_TOKEN_REQUEST_URL'))
|
|
30
|
-
raise ArgumentError, 'OIDC request URL must use HTTPS' unless uri.is_a?(URI::HTTPS)
|
|
31
|
-
|
|
32
|
-
query = URI.decode_www_form(uri.query || '').reject { |name, _value| name == 'audience' } # rubocop:disable Style/HashExcept -- Query pairs may contain duplicate keys.
|
|
33
|
-
uri.query = URI.encode_www_form(query + [['audience', 'rubygems.org']])
|
|
34
|
-
request = Net::HTTP::Get.new(uri)
|
|
35
|
-
request['Authorization'] = "Bearer #{ENV.fetch('ACTIONS_ID_TOKEN_REQUEST_TOKEN')}"
|
|
36
|
-
credential(response(uri, request), 'value')
|
|
37
|
-
end
|
|
38
|
-
|
|
39
28
|
def exchange_token(token)
|
|
40
29
|
uri = URI('https://rubygems.org/api/v1/oidc/trusted_publisher/exchange_token')
|
|
41
30
|
request = Net::HTTP::Post.new(uri)
|
|
42
31
|
request['Content-Type'] = 'application/json'
|
|
43
32
|
request['Accept'] = 'application/json'
|
|
44
|
-
|
|
33
|
+
body = { jwt: token }
|
|
34
|
+
body[:owner] = @owner unless @owner.empty?
|
|
35
|
+
request.body = JSON.generate(body)
|
|
45
36
|
credential(response(uri, request), 'rubygems_api_key')
|
|
46
37
|
end
|
|
47
38
|
|
|
@@ -49,7 +40,7 @@ module RubyGemRelease
|
|
|
49
40
|
result = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30, max_retries: 0) do |http|
|
|
50
41
|
http.request(request)
|
|
51
42
|
end
|
|
52
|
-
raise "Credential request failed (HTTP #{result.code}); verify
|
|
43
|
+
raise "Credential request failed (HTTP #{result.code}); verify trusted publisher configuration and owner ID if supplied" unless result.is_a?(Net::HTTPSuccess)
|
|
53
44
|
|
|
54
45
|
JSON.parse(result.body)
|
|
55
46
|
rescue JSON::ParserError
|
|
@@ -62,10 +53,6 @@ module RubyGemRelease
|
|
|
62
53
|
|
|
63
54
|
value
|
|
64
55
|
end
|
|
65
|
-
|
|
66
|
-
def mask(value)
|
|
67
|
-
puts "::add-mask::#{value.gsub('%', '%25')}"
|
|
68
|
-
end
|
|
69
56
|
end
|
|
70
57
|
end
|
|
71
58
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ruby_gem'
|
|
4
|
+
require_relative '../semantic_release'
|
|
5
|
+
require_relative '../changelog/generated'
|
|
6
|
+
|
|
7
|
+
module RubyGemRelease
|
|
8
|
+
module Workflow
|
|
9
|
+
class SemanticRubyGem < RubyGem
|
|
10
|
+
attr_reader :semantic_release
|
|
11
|
+
|
|
12
|
+
def initialize(semantic_release: SemanticRelease.new, changelog: Changelog::Generated.new, bump: false, **)
|
|
13
|
+
raise ArgumentError, 'bump must be true or false' unless [true, false].include?(bump)
|
|
14
|
+
|
|
15
|
+
@semantic_release = semantic_release
|
|
16
|
+
@bump = bump
|
|
17
|
+
super(changelog: changelog, **)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def install_callbacks(tasks)
|
|
23
|
+
tasks.validate { |ctx| validate_release(ctx) }
|
|
24
|
+
tasks.prepare { |ctx| prepare_semantic_release(ctx) }
|
|
25
|
+
install_result_callbacks(tasks)
|
|
26
|
+
tasks.failure(&@failure) if @failure
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def install_result_callbacks(tasks)
|
|
30
|
+
tasks.publish { |ctx| with_decision(ctx) { publish(ctx.release) } }
|
|
31
|
+
tasks.bump { |ctx| with_decision(ctx) { prepare_next(ctx) if @bump } }
|
|
32
|
+
tasks.success { |ctx| with_decision(ctx) { @success&.call(ctx) } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def with_decision(ctx)
|
|
36
|
+
checked_decision(ctx)
|
|
37
|
+
yield
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_release(ctx)
|
|
41
|
+
decision = ctx.analysis || @semantic_release.analyze(head: @provider.fetch('SOURCE_COMMIT'), explicit_version: @provider.fetch('INPUT_VERSION', ''))
|
|
42
|
+
ctx.record_analysis!(decision) unless ctx.analysis
|
|
43
|
+
raise ArgumentError, 'Nothing to release: no release-eligible commits' unless decision.release?
|
|
44
|
+
|
|
45
|
+
@validate&.call(ctx)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def checked_decision(ctx)
|
|
49
|
+
raise ArgumentError, 'semantic release requires a persisted release decision' unless ctx.analysis&.release?
|
|
50
|
+
|
|
51
|
+
ctx.analysis.check!(head: @provider.fetch('SOURCE_COMMIT'), explicit_version: @provider.fetch('INPUT_VERSION', ''), version: ctx.release.version)
|
|
52
|
+
ctx.analysis
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prepare_semantic_release(ctx)
|
|
56
|
+
checked_decision(ctx)
|
|
57
|
+
@git.stage_changes! do
|
|
58
|
+
select_version!(ctx.release.version)
|
|
59
|
+
@changelog&.release!(ctx.release)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
require_relative '../version/ruby_constant'
|
|
6
|
+
require_relative '../semantic_release/decision'
|
|
7
|
+
|
|
8
|
+
module RubyGemRelease
|
|
9
|
+
module Workflow
|
|
10
|
+
class State
|
|
11
|
+
def self.create!(directory:)
|
|
12
|
+
file = Tempfile.create(['ruby-gem-release-state-', '.json'], directory)
|
|
13
|
+
file.write(JSON.generate('next_version' => nil, 'analysis' => nil))
|
|
14
|
+
file.path
|
|
15
|
+
ensure
|
|
16
|
+
file&.close
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(path: nil)
|
|
20
|
+
@path = path unless path.to_s.empty?
|
|
21
|
+
@next_version = nil
|
|
22
|
+
@analysis = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def next_version
|
|
26
|
+
snapshot.fetch(:next_version)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def analysis
|
|
30
|
+
snapshot.fetch(:analysis)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def snapshot
|
|
34
|
+
return { next_version: @next_version, analysis: @analysis }.freeze unless @path
|
|
35
|
+
|
|
36
|
+
values, analysis = read_state
|
|
37
|
+
{ next_version: values.fetch('next_version')&.freeze, analysis: analysis }.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def record_analysis!(decision)
|
|
41
|
+
raise ArgumentError, 'analysis must be a semantic release decision' unless decision.is_a?(SemanticRelease::Decision)
|
|
42
|
+
raise ArgumentError, 'semantic analysis is already recorded' if analysis
|
|
43
|
+
|
|
44
|
+
if @path
|
|
45
|
+
persist(attributes.merge('analysis' => decision.to_h))
|
|
46
|
+
else
|
|
47
|
+
@analysis = decision
|
|
48
|
+
end
|
|
49
|
+
decision
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def record_next_version!(version)
|
|
53
|
+
validate_version(version)
|
|
54
|
+
if @path
|
|
55
|
+
persist(attributes.merge('next_version' => version))
|
|
56
|
+
else
|
|
57
|
+
@next_version = version.dup.freeze
|
|
58
|
+
end
|
|
59
|
+
version
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def attributes
|
|
65
|
+
read_state.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def read_state
|
|
69
|
+
values = JSON.parse(File.read(@path))
|
|
70
|
+
valid = values.is_a?(Hash) && values.keys.sort == %w[analysis next_version]
|
|
71
|
+
raise ArgumentError, 'unsupported release state' unless valid
|
|
72
|
+
|
|
73
|
+
validate_version(values['next_version']) unless values['next_version'].nil?
|
|
74
|
+
raw_analysis = values['analysis']
|
|
75
|
+
analysis = SemanticRelease::Decision.from_h(raw_analysis) unless raw_analysis.nil?
|
|
76
|
+
[values, analysis]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def persist(values)
|
|
80
|
+
Tempfile.create(['.release-state-', '.json'], File.dirname(@path)) do |file|
|
|
81
|
+
file.write(JSON.generate(values))
|
|
82
|
+
file.close
|
|
83
|
+
File.rename(file.path, @path)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_version(version)
|
|
88
|
+
return if version.is_a?(String) && Version::RubyConstant::PATTERN.match?(version)
|
|
89
|
+
|
|
90
|
+
raise ArgumentError, 'next version must be MAJOR.MINOR.PATCH'
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby-gem-release
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Doubrovkine
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2026-11-10 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: commonmarker
|
|
@@ -50,19 +50,41 @@ files:
|
|
|
50
50
|
- lib/ruby-gem-release.rb
|
|
51
51
|
- lib/ruby-gem-release/changelog.rb
|
|
52
52
|
- lib/ruby-gem-release/changelog/base.rb
|
|
53
|
+
- lib/ruby-gem-release/changelog/generated.rb
|
|
53
54
|
- lib/ruby-gem-release/changelog/intridea.rb
|
|
54
55
|
- lib/ruby-gem-release/changelog/keep_a_changelog.rb
|
|
56
|
+
- lib/ruby-gem-release/destination.rb
|
|
57
|
+
- lib/ruby-gem-release/destination/ruby_gems.rb
|
|
58
|
+
- lib/ruby-gem-release/execution_context.rb
|
|
59
|
+
- lib/ruby-gem-release/execution_context/github.rb
|
|
60
|
+
- lib/ruby-gem-release/execution_context/github/run.rb
|
|
61
|
+
- lib/ruby-gem-release/execution_context/github/step.rb
|
|
62
|
+
- lib/ruby-gem-release/execution_context/step.rb
|
|
63
|
+
- lib/ruby-gem-release/execution_provider.rb
|
|
64
|
+
- lib/ruby-gem-release/execution_provider/github.rb
|
|
65
|
+
- lib/ruby-gem-release/github_api.rb
|
|
55
66
|
- lib/ruby-gem-release/hosting/github_release.rb
|
|
67
|
+
- lib/ruby-gem-release/hosting/github_release/existing.rb
|
|
56
68
|
- lib/ruby-gem-release/package/ruby_gem.rb
|
|
57
69
|
- lib/ruby-gem-release/release.rb
|
|
70
|
+
- lib/ruby-gem-release/release_failure.rb
|
|
71
|
+
- lib/ruby-gem-release/semantic_release.rb
|
|
72
|
+
- lib/ruby-gem-release/semantic_release/conventional_commits.rb
|
|
73
|
+
- lib/ruby-gem-release/semantic_release/decision.rb
|
|
74
|
+
- lib/ruby-gem-release/semantic_release/git_history.rb
|
|
75
|
+
- lib/ruby-gem-release/semantic_release/release_notes.rb
|
|
76
|
+
- lib/ruby-gem-release/semantic_release/version_selector.rb
|
|
58
77
|
- lib/ruby-gem-release/source_control/git.rb
|
|
59
78
|
- lib/ruby-gem-release/source_control/git/preparation.rb
|
|
60
79
|
- lib/ruby-gem-release/tasks.rb
|
|
61
80
|
- lib/ruby-gem-release/version.rb
|
|
62
81
|
- lib/ruby-gem-release/version/ruby_constant.rb
|
|
63
|
-
- lib/ruby-gem-release/workflow/
|
|
82
|
+
- lib/ruby-gem-release/workflow/environment.rb
|
|
83
|
+
- lib/ruby-gem-release/workflow/failure.rb
|
|
64
84
|
- lib/ruby-gem-release/workflow/ruby_gem.rb
|
|
65
85
|
- lib/ruby-gem-release/workflow/ruby_gems_credentials.rb
|
|
86
|
+
- lib/ruby-gem-release/workflow/semantic_ruby_gem.rb
|
|
87
|
+
- lib/ruby-gem-release/workflow/state.rb
|
|
66
88
|
homepage: https://github.com/dblock/ruby-gem-release
|
|
67
89
|
licenses:
|
|
68
90
|
- MIT
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative '../package/ruby_gem'
|
|
4
|
-
require_relative '../source_control/git'
|
|
5
|
-
require_relative '../release'
|
|
6
|
-
require_relative '../version/ruby_constant'
|
|
7
|
-
|
|
8
|
-
module RubyGemRelease
|
|
9
|
-
module Workflow
|
|
10
|
-
class Context
|
|
11
|
-
def configure!
|
|
12
|
-
names = %w[INPUT_VERSION INPUT_GEMSPEC DEFAULT_BRANCH GIT_USER_NAME GIT_USER_EMAIL RUBYGEMS_OWNER]
|
|
13
|
-
timezone = ENV.fetch('TZ', 'UTC')
|
|
14
|
-
validate_timezone(timezone)
|
|
15
|
-
export(names.to_h { |name| [name, ENV.fetch(name)] }.merge('TZ' => timezone))
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def capture!
|
|
19
|
-
check_branch
|
|
20
|
-
validate_timezone(ENV.fetch('TZ', 'UTC'))
|
|
21
|
-
git = configured_git
|
|
22
|
-
git.check_clean!
|
|
23
|
-
package = Package::RubyGem.new(gemspec: ENV.fetch('INPUT_GEMSPEC', nil))
|
|
24
|
-
release = initial_release(package)
|
|
25
|
-
git.check_current!(release)
|
|
26
|
-
export('TZ' => ENV.fetch('TZ', 'UTC'), 'GEMSPEC' => package.gemspec, 'RELEASE_PREVIOUS_VERSION' => release.version,
|
|
27
|
-
'RELEASE_DATE' => release.date.iso8601, 'RELEASE_INITIAL_COMMIT' => git.head)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def resolve!
|
|
31
|
-
package = Package::RubyGem.new
|
|
32
|
-
version = prepared_version(package)
|
|
33
|
-
git = SourceControl::Git.new
|
|
34
|
-
git.check_tag!(version)
|
|
35
|
-
package.check_version!(version)
|
|
36
|
-
git.stage_changes!
|
|
37
|
-
export('RELEASE_VERSION' => version)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
def prepared_version(package)
|
|
43
|
-
version = package.version
|
|
44
|
-
validate_version(version)
|
|
45
|
-
selected = ENV.fetch('INPUT_VERSION', '')
|
|
46
|
-
raise "Prepared version #{version} does not match selected version #{selected}" unless selected.empty? || version == selected
|
|
47
|
-
|
|
48
|
-
version
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def initial_release(package)
|
|
52
|
-
version = package.version
|
|
53
|
-
validate_version(version)
|
|
54
|
-
validate_version(ENV.fetch('INPUT_VERSION')) unless ENV.fetch('INPUT_VERSION', '').empty?
|
|
55
|
-
Release.new(version: version, previous_version: version, date: release_date)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def release_date
|
|
59
|
-
(ENV.key?('TZ') ? Time.now.getlocal : Time.now.utc).to_date
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def validate_timezone(timezone)
|
|
63
|
-
valid = %r{\A[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)*\z}.match?(timezone) &&
|
|
64
|
-
File.file?(File.join('/usr/share/zoneinfo', timezone))
|
|
65
|
-
raise ArgumentError, 'timezone must identify an installed timezone, such as UTC or America/New_York' unless valid
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def configured_git
|
|
69
|
-
SourceControl::Git.new.tap do |git|
|
|
70
|
-
git.configure!(name: ENV.fetch('GIT_USER_NAME', 'github-actions[bot]'),
|
|
71
|
-
email: ENV.fetch('GIT_USER_EMAIL', 'github-actions[bot]@users.noreply.github.com'))
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def check_branch
|
|
76
|
-
return if ENV.fetch('GITHUB_REF') == "refs/heads/#{ENV.fetch('DEFAULT_BRANCH')}"
|
|
77
|
-
|
|
78
|
-
raise 'Releases must run from the default branch'
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def validate_version(version)
|
|
82
|
-
raise ArgumentError, 'Version must be MAJOR.MINOR.PATCH without leading zeros' unless Version::RubyConstant::PATTERN.match?(version)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def export(values)
|
|
86
|
-
raise ArgumentError, 'Workflow values cannot contain line breaks' if values.values.any? { |value| value.match?(/[\r\n]/) }
|
|
87
|
-
|
|
88
|
-
File.open(ENV.fetch('GITHUB_ENV'), 'a') do |file|
|
|
89
|
-
values.each do |key, value|
|
|
90
|
-
file.puts "#{key}=#{value}"
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|