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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require_relative 'decision'
5
+
6
+ module RubyGemRelease
7
+ class SemanticRelease
8
+ class GitHistory
9
+ def read(head:)
10
+ check_head!(head)
11
+ last_release = last_release(head)
12
+ range = last_release ? "#{last_release.fetch('commit')}..#{head}" : head
13
+ entries = run('log', '--reverse', '-z', '--format=%H%x00%B', range, '--').split("\0")
14
+ commits = entries.each_slice(2).map { |sha, message| { 'sha' => sha, 'message' => message } }
15
+ { last_release: last_release, commits: commits }
16
+ end
17
+
18
+ private
19
+
20
+ def check_head!(head)
21
+ raise ArgumentError, 'semantic history requires the captured commit SHA' unless head.is_a?(String) && Decision::SHA.match?(head)
22
+ raise ArgumentError, 'semantic releases require a complete checkout; fetch full history and tags' if run('rev-parse', '--is-shallow-repository').strip == 'true'
23
+ raise ArgumentError, 'checkout changed before semantic analysis' unless run('rev-parse', 'HEAD').strip == head
24
+ end
25
+
26
+ def last_release(head)
27
+ tags = run('tag', '--merged', head).lines.map(&:strip)
28
+ tags.select! { |tag| tag.start_with?('v') && Version::RubyConstant::PATTERN.match?(tag.delete_prefix('v')) }
29
+ tag = tags.max_by { |candidate| Gem::Version.new(candidate.delete_prefix('v')) }
30
+ return unless tag
31
+
32
+ { 'tag' => tag, 'version' => tag.delete_prefix('v'), 'commit' => run('rev-parse', "refs/tags/#{tag}^{commit}").strip }
33
+ end
34
+
35
+ def run(*)
36
+ output, error, status = Open3.capture3('git', *)
37
+ raise "Semantic history Git command failed: #{error.strip}" unless status.success?
38
+
39
+ output
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'conventional_commits'
4
+
5
+ module RubyGemRelease
6
+ class SemanticRelease
7
+ class ReleaseNotes
8
+ GROUPS = { 'feat' => 'Features', 'fix' => 'Bug Fixes', 'perf' => 'Performance Improvements' }.freeze
9
+
10
+ def generate(commits:, **)
11
+ parsed = ConventionalCommits.new.parsed(commits)
12
+ sections = []
13
+ breaking = breaking_section(parsed)
14
+ sections << breaking if breaking
15
+ sections.concat(change_sections(parsed))
16
+ sections.empty? ? 'Explicit version release.' : sections.join("\n\n")
17
+ end
18
+
19
+ private
20
+
21
+ def breaking_section(commits)
22
+ notes = commits.flat_map { |commit| commit[:notes] }
23
+ "### Breaking Changes\n\n#{notes.map { |note| "- #{note}" }.join("\n")}" unless notes.empty?
24
+ end
25
+
26
+ def change_sections(commits)
27
+ commits.group_by { |commit| GROUPS.fetch(commit[:type], 'Other Changes') }.map do |title, group|
28
+ entries = group.map { |commit| "- #{description(commit)} (#{commit[:sha][0, 7]})" }
29
+ "### #{title}\n\n#{entries.join("\n")}"
30
+ end
31
+ end
32
+
33
+ def description(commit)
34
+ subject = commit[:subject].gsub(/([\\`*_\[\]<>])/, '\\\\\1')
35
+ scope = commit[:scope]&.gsub(/([\\`*_\[\]<>])/, '\\\\\1')
36
+ scope ? "**#{scope}:** #{subject}" : subject
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'decision'
4
+
5
+ module RubyGemRelease
6
+ class SemanticRelease
7
+ class VersionSelector
8
+ def initialize(first_version: '1.0.0')
9
+ validate(first_version)
10
+ @first_version = first_version.dup.freeze
11
+ end
12
+
13
+ def select(last_release:, release_type:, explicit_version: nil)
14
+ explicit_version = nil if explicit_version == ''
15
+ validate_options(last_release, release_type, explicit_version)
16
+ version = explicit_version || increment(last_release, release_type)
17
+ raise ArgumentError, 'selected version must exceed the last release' if version && last_release && Gem::Version.new(version) <= Gem::Version.new(last_release)
18
+
19
+ version
20
+ end
21
+
22
+ private
23
+
24
+ def validate_options(last_release, type, explicit)
25
+ validate(last_release) if last_release
26
+ validate(explicit) if explicit
27
+ raise ArgumentError, 'unsupported release type' unless type.nil? || Decision::TYPES.include?(type)
28
+ end
29
+
30
+ def increment(version, type)
31
+ return unless type
32
+ return @first_version unless version
33
+
34
+ parts = version.split('.').map(&:to_i)
35
+ index = { 'major' => 0, 'minor' => 1, 'patch' => 2 }.fetch(type)
36
+ parts[index] += 1
37
+ ((index + 1)..2).each { |position| parts[position] = 0 }
38
+ parts.join('.')
39
+ end
40
+
41
+ def validate(version)
42
+ return if version.is_a?(String) && Version::RubyConstant::PATTERN.match?(version)
43
+
44
+ raise ArgumentError, 'semantic versions must be MAJOR.MINOR.PATCH without leading zeros'
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'semantic_release/git_history'
4
+ require_relative 'semantic_release/conventional_commits'
5
+ require_relative 'semantic_release/version_selector'
6
+ require_relative 'semantic_release/release_notes'
7
+
8
+ module RubyGemRelease
9
+ class SemanticRelease
10
+ def initialize(history: GitHistory.new, analyzer: ConventionalCommits.new, selector: VersionSelector.new, notes: ReleaseNotes.new)
11
+ @history = history
12
+ @analyzer = analyzer
13
+ @selector = selector
14
+ @notes = notes
15
+ end
16
+
17
+ def analyze(head:, explicit_version: nil)
18
+ explicit_version = nil if explicit_version == ''
19
+ history = @history.read(head: head)
20
+ type = @analyzer.analyze(history.fetch(:commits))
21
+ last_release = history.fetch(:last_release)
22
+ version = @selector.select(last_release: last_release&.fetch('version'), release_type: type, explicit_version: explicit_version)
23
+ notes = @notes.generate(commits: history.fetch(:commits), version: version, last_release: last_release) if version
24
+ Decision.new(head: head, last_release: last_release, commits: history.fetch(:commits),
25
+ release_type: type, version: version, notes: notes, explicit_version: explicit_version)
26
+ end
27
+ end
28
+ end
@@ -20,7 +20,7 @@ module RubyGemRelease
20
20
 
21
21
  def stage_changes!
22
22
  if block_given?
23
- check_clean!
23
+ verify!
24
24
  yield
25
25
  end
26
26
  paths = changed_paths
@@ -11,10 +11,11 @@ module RubyGemRelease
11
11
  def initialize(branch: nil, remote: 'origin', directory: nil)
12
12
  @remote = remote
13
13
  @branch = branch || ENV.fetch('DEFAULT_BRANCH', nil)
14
- @directory = directory || ENV['RUNNER_TEMP'] || Dir.mktmpdir('ruby-gem-release-git-')
14
+ directory ||= ENV.fetch('RELEASE_DATA_DIR', nil)
15
+ @directory = directory.to_s.empty? ? Dir.mktmpdir('ruby-gem-release-git-') : directory
15
16
  end
16
17
 
17
- def configure!(name: 'github-actions[bot]', email: 'github-actions[bot]@users.noreply.github.com')
18
+ def configure!(name: 'ruby-gem-release', email: 'ruby-gem-release@localhost')
18
19
  [name, email].each do |value|
19
20
  raise ArgumentError, 'Git identity must be nonempty and contain no line breaks' unless value.is_a?(String) && !value.strip.empty? && !value.match?(/[\r\n]/)
20
21
  end
@@ -26,15 +27,13 @@ module RubyGemRelease
26
27
  run('rev-parse', 'HEAD').strip
27
28
  end
28
29
 
29
- def check_tag!(version)
30
- output, _error, status = Open3.capture3('git', 'show-ref', '--verify', '--quiet', "refs/tags/v#{version}")
31
- raise "Release tag already exists: v#{version}" if status.success?
32
- raise "Cannot check release tag: #{output}" unless status.exitstatus == 1
30
+ def timestamp(commit)
31
+ run('show', '-s', '--format=%ct', commit).strip
33
32
  end
34
33
 
35
- def check_current!(_release, expected: head)
34
+ def check_current!(expected: head, allow_ancestor: false)
36
35
  run('fetch', @remote, branch, '--tags')
37
- raise 'Default branch advanced; start a new release' unless remote_head == expected
36
+ raise 'Default branch advanced; start a new release' unless remote_head == expected || (allow_ancestor && ancestor?(expected, remote_head))
38
37
  end
39
38
 
40
39
  def check_clean!
@@ -47,7 +46,11 @@ module RubyGemRelease
47
46
 
48
47
  def commit!(release)
49
48
  @release = release
50
- @initial_commit = ENV['RELEASE_INITIAL_COMMIT'] || head
49
+ @next_commit_parent = nil
50
+ @source_commit = ENV['SOURCE_COMMIT'] || head
51
+ existing = tag_commit(release.version)
52
+ return reuse_release!(existing) if existing
53
+
51
54
  commit_changes("Prepare release #{release.version}", required: false)
52
55
  end
53
56
 
@@ -56,33 +59,126 @@ module RubyGemRelease
56
59
 
57
60
  @release = release
58
61
  @next_commit_parent = head
62
+ existing = next_iteration(release)
63
+ return reuse_iteration!(existing, release) if existing
64
+
59
65
  commit_changes("Prepare next development iteration, #{release.next_version}", required: true)
60
66
  end
61
67
 
62
68
  def tag!(release)
63
69
  @release = release
64
70
  ensure_clean!
65
- check_current!(release, expected: @initial_commit || ENV.fetch('RELEASE_INITIAL_COMMIT'))
66
- check_tag!(release.version)
67
- run('tag', "v#{release.version}")
68
- FileUtils.mkdir_p(@directory)
69
- run('bundle', 'create', File.join(@directory, 'release.bundle'), 'HEAD', "refs/tags/v#{release.version}")
71
+ fetch!
72
+ existing = tag_commit(release.version)
73
+ raise "Release tag already exists at another commit: v#{release.version}" if existing && existing != head
74
+
75
+ raise 'Default branch advanced; start a new release' unless release_current?(existing)
76
+
77
+ run('tag', "v#{release.version}") unless existing
78
+ write_bundle(release)
70
79
  end
71
80
 
72
81
  def push!(release = @release)
73
82
  raise ArgumentError, 'a release context is required before pushing' unless release
74
83
 
75
- if @next_commit_parent
76
- check_current!(release, expected: @next_commit_parent)
77
- run('push', @remote, "HEAD:refs/heads/#{branch}")
78
- else
79
- check_current!(release, expected: @initial_commit || ENV.fetch('RELEASE_INITIAL_COMMIT'))
80
- run('push', '--atomic', @remote, "HEAD:refs/heads/#{branch}", "refs/tags/v#{release.version}")
81
- end
84
+ fetch!
85
+ @next_commit_parent ? push_iteration! : push_release!(release)
86
+ end
87
+
88
+ def fetch!
89
+ run('fetch', @remote, branch, '--tags')
90
+ end
91
+
92
+ def tag_commit(version)
93
+ ref = "refs/tags/v#{version}"
94
+ _output, error, status = Open3.capture3('git', 'show-ref', '--verify', '--quiet', ref)
95
+ return if status.exitstatus == 1
96
+ raise "Cannot inspect release tag: #{error}" unless status.success?
97
+
98
+ run('rev-parse', "#{ref}^{commit}").strip
99
+ end
100
+
101
+ def next_iteration(release)
102
+ fetch!
103
+ released = tag_commit(release.version)
104
+ return unless released && ancestor?(released, remote_head)
105
+
106
+ candidate = iteration_after(released)
107
+ return unless candidate
108
+
109
+ version = run('log', '-1', '--format=%s', candidate)[/\APrepare next development iteration, (\d+\.\d+\.\d+)\n?\z/, 1]
110
+ { commit: candidate, version: version, tree: run('rev-parse', "#{candidate}^{tree}").strip } if version
82
111
  end
83
112
 
84
113
  private
85
114
 
115
+ def reuse_release!(existing)
116
+ preparation.verify!
117
+ raise 'Existing release tag differs from the intended tree' unless run('write-tree').strip == run('rev-parse', "#{existing}^{tree}").strip
118
+
119
+ run('update-ref', 'HEAD', existing, head)
120
+ preparation.clear!
121
+ head
122
+ end
123
+
124
+ def reuse_iteration!(existing, release)
125
+ preparation.verify!
126
+ raise 'Existing development iteration differs from the intended changes' unless existing.fetch(:version) == release.next_version && existing.fetch(:tree) == run('write-tree').strip
127
+
128
+ run('update-ref', 'HEAD', existing.fetch(:commit), head)
129
+ preparation.clear!
130
+ head
131
+ end
132
+
133
+ def release_current?(existing)
134
+ expected = @source_commit || ENV.fetch('SOURCE_COMMIT')
135
+ remote_head == expected || (existing && ancestor?(head, remote_head))
136
+ end
137
+
138
+ def write_bundle(release)
139
+ FileUtils.mkdir_p(@directory)
140
+ run('bundle', 'create', File.join(@directory, 'release.bundle'), 'HEAD', "refs/tags/v#{release.version}")
141
+ end
142
+
143
+ def push_iteration!
144
+ return if ancestor?(head, remote_head)
145
+
146
+ check_current!(expected: @next_commit_parent)
147
+ run('push', @remote, "HEAD:refs/heads/#{branch}")
148
+ end
149
+
150
+ def push_release!(release)
151
+ return push_missing_tag(release) if ancestor?(head, remote_head)
152
+
153
+ check_current!(expected: @source_commit || ENV.fetch('SOURCE_COMMIT'))
154
+ run('push', '--atomic', @remote, "HEAD:refs/heads/#{branch}", "refs/tags/v#{release.version}")
155
+ end
156
+
157
+ def iteration_after(released)
158
+ candidate = run('rev-list', '--first-parent', '--reverse', "#{released}..#{remote_head}").lines.first&.strip
159
+ candidate if candidate && run('rev-list', '--parents', '-n', '1', candidate).split == [candidate, released]
160
+ end
161
+
162
+ def ancestor?(commit, descendant)
163
+ _output, error, status = Open3.capture3('git', 'merge-base', '--is-ancestor', commit, descendant)
164
+ raise "Cannot inspect Git ancestry: #{error}" unless [0, 1].include?(status.exitstatus)
165
+
166
+ status.success?
167
+ end
168
+
169
+ def remote_tag(version)
170
+ output = run('ls-remote', '--tags', @remote, "refs/tags/v#{version}", "refs/tags/v#{version}^{}")
171
+ entries = output.lines.map(&:split).to_h { |sha, ref| [ref, sha] }
172
+ entries["refs/tags/v#{version}^{}"] || entries["refs/tags/v#{version}"]
173
+ end
174
+
175
+ def push_missing_tag(release)
176
+ existing = remote_tag(release.version)
177
+ raise 'Remote release tag points at another commit' if existing && existing != head
178
+
179
+ run('push', @remote, "refs/tags/v#{release.version}") unless existing
180
+ end
181
+
86
182
  def branch
87
183
  @branch ||= run('symbolic-ref', "refs/remotes/#{@remote}/HEAD").strip.delete_prefix("refs/remotes/#{@remote}/")
88
184
  end
@@ -2,40 +2,60 @@
2
2
 
3
3
  require 'rake'
4
4
  require_relative 'release'
5
+ require_relative 'release_failure'
6
+ require_relative 'execution_provider'
5
7
  require_relative 'version/ruby_constant'
6
- require_relative 'workflow/context'
8
+ require_relative 'workflow/environment'
9
+ require_relative 'workflow/failure'
7
10
  require_relative 'workflow/ruby_gems_credentials'
11
+ require_relative 'destination/ruby_gems'
8
12
 
9
13
  module RubyGemRelease
10
14
  class Tasks
11
15
  include Rake::DSL
12
16
 
13
- def self.install
14
- instance = new
17
+ def self.install(workflow: nil, package: nil, destination: Destination::RubyGems.new, provider: ExecutionProvider.current)
18
+ instance = new(workflow: workflow, package: package, destination: destination, provider: provider)
15
19
  yield instance if block_given?
16
20
  instance.install
17
21
  end
18
22
 
23
+ def initialize(workflow: nil, package: nil, destination: Destination::RubyGems.new, provider: ExecutionProvider.current)
24
+ @workflow = workflow
25
+ @package = package
26
+ @destination = destination
27
+ @provider = provider
28
+ end
29
+
19
30
  def install
20
31
  namespace :release do
32
+ install_validate_task
21
33
  install_callback_tasks
22
34
  install_publish_task
35
+ install_result_tasks
23
36
  install_workflow_tasks
24
37
  end
25
38
  end
26
39
 
27
- def before_release(&block)
28
- raise ArgumentError, 'before_release requires a block' unless block
29
- raise ArgumentError, 'before_release already configured' if @before_release
40
+ def validate(&block)
41
+ raise ArgumentError, 'validate requires a block' unless block
42
+ raise ArgumentError, 'validate already configured' if @validate
30
43
 
31
- @before_release = block
44
+ @validate = block
32
45
  end
33
46
 
34
- def after_release(&block)
35
- raise ArgumentError, 'after_release requires a block' unless block
36
- raise ArgumentError, 'after_release already configured' if @after_release
47
+ def prepare(&block)
48
+ raise ArgumentError, 'prepare requires a block' unless block
49
+ raise ArgumentError, 'prepare already configured' if @prepare
37
50
 
38
- @after_release = block
51
+ @prepare = block
52
+ end
53
+
54
+ def bump(&block)
55
+ raise ArgumentError, 'bump requires a block' unless block
56
+ raise ArgumentError, 'bump already configured' if @bump
57
+
58
+ @bump = block
39
59
  end
40
60
 
41
61
  def publish(&block)
@@ -45,50 +65,188 @@ module RubyGemRelease
45
65
  @publish = block
46
66
  end
47
67
 
68
+ def success(&block)
69
+ raise ArgumentError, 'success requires a block' unless block
70
+ raise ArgumentError, 'success already configured' if @success
71
+
72
+ @success = block
73
+ end
74
+
75
+ def failure(&block)
76
+ raise ArgumentError, 'failure requires a block' unless block
77
+ raise ArgumentError, 'failure already configured' if @failure
78
+
79
+ @failure = block
80
+ end
81
+
48
82
  private
49
83
 
84
+ def install_validate_task
85
+ desc 'Validate release preconditions'
86
+ task :validate do
87
+ with_failure_capture('release:validate') { validate_release }
88
+ end
89
+ end
90
+
91
+ def validate_release
92
+ raise ArgumentError, 'Release validation failed: callback returned false' if @validate && @validate.call(context('release:validate', preparing: true)) == false
93
+
94
+ puts 'Release validation passed.'
95
+ end
96
+
50
97
  def install_workflow_tasks
51
98
  namespace :workflow do
52
- task(:configure) { Workflow::Context.new.configure! }
53
- task(:capture) { Workflow::Context.new.capture! }
54
- task(:resolve) { Workflow::Context.new.resolve! }
55
- task(:credentials) { Workflow::RubyGemsCredentials.new(owner: ENV.fetch('RUBYGEMS_OWNER')).configure! }
99
+ install_workflow_context_tasks
100
+ task(:failure_phase) { Workflow::Failure.phase!(provider: @provider) }
101
+ task(:credentials) do
102
+ with_failure_capture('release:workflow:credentials') do
103
+ Workflow::RubyGemsCredentials.new(owner: @provider.fetch('RUBYGEMS_OWNER_ID', ''), provider: @provider).configure!
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ def install_workflow_context_tasks
110
+ install_checkpoint_task
111
+ task(:configure) { with_failure_capture('release:workflow:configure') { Workflow::Environment.new(provider: @provider).configure! } }
112
+ task(:capture) { with_failure_capture('release:workflow:capture') { Workflow::Environment.new(provider: @provider).capture!(package: @package) } }
113
+ task(:resolve) do
114
+ with_failure_capture('release:workflow:resolve') do
115
+ Workflow::Environment.new(provider: @provider).resolve!(package: @package, destination: @destination, analysis: state.analysis)
116
+ end
117
+ end
118
+ end
119
+
120
+ def install_checkpoint_task
121
+ task(:checkpoint) do
122
+ with_failure_capture('release:workflow:checkpoint') do
123
+ raise 'Workflow must implement checkpoint! before publication' unless @workflow
124
+
125
+ @workflow.checkpoint!(context('release:workflow:checkpoint'))
126
+ end
56
127
  end
57
128
  end
58
129
 
59
130
  def install_publish_task
60
131
  desc 'Run the release publication callback'
61
132
  task :publish do
62
- @publish&.call(context)
133
+ with_failure_capture('release:publish') do
134
+ next unless @publish
135
+
136
+ ctx = context('release:publish')
137
+ @destination.authenticate!(provider: @provider) unless @workflow&.published?(ctx.release)
138
+ @publish.call(ctx)
139
+ end
140
+ end
141
+ end
142
+
143
+ def install_result_tasks
144
+ desc 'Run the release success callback'
145
+ task :success do
146
+ with_failure_capture('release:success') { @success&.call(context('release:success')) }
147
+ end
148
+ desc 'Run the release failure callback'
149
+ task :failure do
150
+ @failure&.call(failure_context)
63
151
  end
64
152
  end
65
153
 
66
154
  def install_callback_tasks
67
- desc 'Run the before-release callback'
68
- task(:before_release) do
69
- release = context(before_release: true)
70
- @before_release&.call(release)
155
+ install_prepare_task
156
+ install_bump_task
157
+ end
158
+
159
+ def install_prepare_task
160
+ desc 'Run the release preparation callback'
161
+ task(:prepare) do
162
+ with_failure_capture('release:prepare') do
163
+ execution = context('release:prepare', preparing: true)
164
+ @prepare&.call(execution)
165
+ end
71
166
  end
167
+ end
168
+
169
+ def install_bump_task
72
170
  desc 'Run the next development iteration preparation callback'
73
- task(:after_release) do
74
- release = context
75
- @after_release&.call(release)
171
+ task(:bump) do
172
+ with_failure_capture('release:bump') do
173
+ execution = context('release:bump')
174
+ @bump&.call(execution)
175
+ end
176
+ end
177
+ end
178
+
179
+ def context(task, preparing: false)
180
+ snapshot = state.snapshot
181
+ release = release_context(preparing: preparing, **snapshot, validating: task == 'release:validate')
182
+ @provider.context(release: release, task: task, state: state, workflow: @workflow, analysis: snapshot.fetch(:analysis))
183
+ end
184
+
185
+ def state
186
+ path = @provider.fetch('RELEASE_STATE_FILE', nil)
187
+ if !defined?(@state) || @state_path != path
188
+ @state_path = path
189
+ @state = Workflow::State.new(path: path)
76
190
  end
191
+ @state
77
192
  end
78
193
 
79
- def context(before_release: false)
80
- previous_version = ENV.fetch('RELEASE_PREVIOUS_VERSION')
81
- version = before_release ? preparation_version(previous_version) : ENV.fetch('RELEASE_VERSION')
82
- date = ENV.fetch('RELEASE_DATE')
83
- validate_context_versions(version, previous_version)
194
+ def release_context(preparing: false, analysis: nil, next_version: nil, validating: false)
195
+ gemspec_version = @provider.fetch('GEMSPEC_VERSION')
196
+ version = preparing ? preparation_version(gemspec_version, analysis: analysis, validating: validating) : @provider.fetch('RELEASE_VERSION')
197
+ date = @provider.fetch('RELEASE_DATE')
198
+ analysis&.check!(head: @provider.fetch('SOURCE_COMMIT'), explicit_version: @provider.fetch('INPUT_VERSION', ''), version: validating ? nil : version)
199
+ validate_context_versions(version, gemspec_version)
84
200
  raise ArgumentError, 'release date is required in YYYY-MM-DD format' unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
85
201
 
86
- Release.new(version: version, previous_version: previous_version, date: Date.iso8601(date))
202
+ Release.new(version: version, gemspec_version: gemspec_version, date: Date.iso8601(date), next_version: next_version, notes: analysis&.notes)
203
+ end
204
+
205
+ def preparation_version(gemspec_version, analysis: nil, validating: false)
206
+ if analysis
207
+ return analysis.version if analysis.release?
208
+ return gemspec_version if validating
209
+
210
+ raise ArgumentError, 'semantic analysis selected no release'
211
+ end
212
+ selected = @provider.fetch('INPUT_VERSION', '')
213
+ selected.empty? ? gemspec_version : selected
214
+ end
215
+
216
+ def failure_context
217
+ phase = @provider.fetch('RELEASE_FAILED_STEP_ID', 'unknown')
218
+ error = Workflow::Failure.error_for(phase, provider: @provider) unless phase == 'unknown'
219
+ safe_state, snapshot = failure_snapshot
220
+ execution = @provider.context(release: failure_release_context(**snapshot), task: 'release:failure', state: safe_state, workflow: @workflow, analysis: snapshot.fetch(:analysis))
221
+ ReleaseFailure.new(phase: phase, error: error, context: execution)
222
+ end
223
+
224
+ def failure_release_context(analysis: nil, next_version: nil)
225
+ gemspec_version = @provider.fetch('GEMSPEC_VERSION', nil)
226
+ date = @provider.fetch('RELEASE_DATE', '')
227
+ return unless gemspec_version && date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
228
+
229
+ version = @provider.fetch('RELEASE_VERSION', '')
230
+ version = preparation_version(gemspec_version, analysis: analysis) if version.empty?
231
+ validate_context_versions(version, gemspec_version)
232
+ Release.new(version: version, gemspec_version: gemspec_version, date: Date.iso8601(date), next_version: next_version, notes: analysis&.notes)
233
+ rescue ArgumentError
234
+ nil
235
+ end
236
+
237
+ def failure_snapshot
238
+ [state, state.snapshot]
239
+ rescue ArgumentError, JSON::ParserError, SystemCallError => e
240
+ warn "Unable to read release state for failure callback: #{e.message}"
241
+ safe_state = Workflow::State.new
242
+ [safe_state, safe_state.snapshot]
87
243
  end
88
244
 
89
- def preparation_version(previous_version)
90
- selected = ENV.fetch('INPUT_VERSION', '')
91
- selected.empty? ? previous_version : selected
245
+ def with_failure_capture(task)
246
+ yield
247
+ rescue StandardError => e
248
+ Workflow::Failure.capture!(@provider.step_id(task), e, provider: @provider)
249
+ raise
92
250
  end
93
251
 
94
252
  def validate_context_versions(*versions)
@@ -27,14 +27,18 @@ module RubyGemRelease
27
27
  value
28
28
  end
29
29
 
30
- def increment!(part: :patch)
31
- parts = parse(read)
30
+ def increment!(part: :patch, from: read)
31
+ set!(next_version(part: part, from: from))
32
+ end
33
+
34
+ def next_version(part: :patch, from: read)
35
+ parts = parse(from)
32
36
  index = { 'major' => 0, 'minor' => 1, 'patch' => 2 }.fetch(part.to_s) do
33
37
  raise ArgumentError, 'bump part must be major, minor, or patch'
34
38
  end
35
39
  parts[index] += 1
36
40
  ((index + 1)...parts.size).each { |i| parts[i] = 0 }
37
- set!(parts.join('.'))
41
+ parts.join('.')
38
42
  end
39
43
 
40
44
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyGemRelease
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end