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,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
|
|
@@ -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
|
-
|
|
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: '
|
|
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
|
|
30
|
-
|
|
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!(
|
|
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
|
-
@
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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/
|
|
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
|
|
28
|
-
raise ArgumentError, '
|
|
29
|
-
raise ArgumentError, '
|
|
40
|
+
def validate(&block)
|
|
41
|
+
raise ArgumentError, 'validate requires a block' unless block
|
|
42
|
+
raise ArgumentError, 'validate already configured' if @validate
|
|
30
43
|
|
|
31
|
-
@
|
|
44
|
+
@validate = block
|
|
32
45
|
end
|
|
33
46
|
|
|
34
|
-
def
|
|
35
|
-
raise ArgumentError, '
|
|
36
|
-
raise ArgumentError, '
|
|
47
|
+
def prepare(&block)
|
|
48
|
+
raise ArgumentError, 'prepare requires a block' unless block
|
|
49
|
+
raise ArgumentError, 'prepare already configured' if @prepare
|
|
37
50
|
|
|
38
|
-
@
|
|
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
|
-
|
|
53
|
-
task(:
|
|
54
|
-
task(:
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(:
|
|
74
|
-
release
|
|
75
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
version =
|
|
82
|
-
date =
|
|
83
|
-
|
|
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,
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
parts.join('.')
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
private
|