toys-release 0.1.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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +11 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.md +21 -0
  5. data/README.md +87 -0
  6. data/docs/guide.md +7 -0
  7. data/lib/toys/release/version.rb +11 -0
  8. data/lib/toys-release.rb +23 -0
  9. data/toys/.data/templates/gh-pages-404.html.erb +25 -0
  10. data/toys/.data/templates/gh-pages-empty.html.erb +11 -0
  11. data/toys/.data/templates/gh-pages-gitignore.erb +1 -0
  12. data/toys/.data/templates/gh-pages-index.html.erb +15 -0
  13. data/toys/.data/templates/release-hook-on-closed.yml.erb +34 -0
  14. data/toys/.data/templates/release-hook-on-open.yml.erb +30 -0
  15. data/toys/.data/templates/release-hook-on-push.yml.erb +32 -0
  16. data/toys/.data/templates/release-perform.yml.erb +46 -0
  17. data/toys/.data/templates/release-request.yml.erb +37 -0
  18. data/toys/.data/templates/release-retry.yml.erb +42 -0
  19. data/toys/.lib/toys/release/artifact_dir.rb +70 -0
  20. data/toys/.lib/toys/release/change_set.rb +259 -0
  21. data/toys/.lib/toys/release/changelog_file.rb +136 -0
  22. data/toys/.lib/toys/release/component.rb +388 -0
  23. data/toys/.lib/toys/release/environment_utils.rb +246 -0
  24. data/toys/.lib/toys/release/performer.rb +346 -0
  25. data/toys/.lib/toys/release/pull_request.rb +154 -0
  26. data/toys/.lib/toys/release/repo_settings.rb +855 -0
  27. data/toys/.lib/toys/release/repository.rb +661 -0
  28. data/toys/.lib/toys/release/request_logic.rb +217 -0
  29. data/toys/.lib/toys/release/request_spec.rb +188 -0
  30. data/toys/.lib/toys/release/semver.rb +112 -0
  31. data/toys/.lib/toys/release/steps.rb +580 -0
  32. data/toys/.lib/toys/release/version_rb_file.rb +91 -0
  33. data/toys/.toys.rb +5 -0
  34. data/toys/_onclosed.rb +113 -0
  35. data/toys/_onopen.rb +158 -0
  36. data/toys/_onpush.rb +57 -0
  37. data/toys/create-labels.rb +115 -0
  38. data/toys/gen-gh-pages.rb +146 -0
  39. data/toys/gen-settings.rb +46 -0
  40. data/toys/gen-workflows.rb +70 -0
  41. data/toys/perform.rb +152 -0
  42. data/toys/request.rb +162 -0
  43. data/toys/retry.rb +133 -0
  44. metadata +106 -0
@@ -0,0 +1,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "change_set"
4
+ require_relative "changelog_file"
5
+ require_relative "version_rb_file"
6
+
7
+ module Toys
8
+ module Release
9
+ ##
10
+ # Represents a particular releasable component in the release system
11
+ #
12
+ class Component
13
+ ##
14
+ # Factory method
15
+ #
16
+ # @param repo_settings [Toys::Release::RepoSettings] the repo settings
17
+ # @param name [String] The component name
18
+ # @param environment_utils [Toys::Release::EnvironmentUtils] env utils
19
+ #
20
+ def self.build(repo_settings, name, environment_utils)
21
+ settings = repo_settings.component_settings(name)
22
+ if settings.type == "gem"
23
+ GemComponent.new(repo_settings, settings, environment_utils)
24
+ else
25
+ Component.new(repo_settings, settings, environment_utils)
26
+ end
27
+ end
28
+
29
+ # @private
30
+ def initialize(repo_settings, settings, environment_utils)
31
+ @repo_settings = repo_settings
32
+ @settings = settings
33
+ @utils = environment_utils
34
+ @changelog_file = ChangelogFile.new(changelog_path(from: :absolute), @utils)
35
+ @version_rb_file = VersionRbFile.new(version_rb_path(from: :absolute), @utils, @settings.version_constant)
36
+ @coordination_group = nil
37
+ end
38
+
39
+ ##
40
+ # @return [Toys::Release::ComponentSettings] The component settings
41
+ #
42
+ attr_reader :settings
43
+
44
+ ##
45
+ # @return [Toys::Release::ChangelogFile] The changelog file in this
46
+ # component
47
+ #
48
+ attr_reader :changelog_file
49
+
50
+ ##
51
+ # @return [Toys::Release::VersionRbFile] The version.rb file in this
52
+ # component
53
+ #
54
+ attr_reader :version_rb_file
55
+
56
+ ##
57
+ # @return [Array<Component>] The coordination group containing this
58
+ # component. If this component is not coordinated, it will be part of
59
+ # a one-element coordination group.
60
+ #
61
+ attr_reader :coordination_group
62
+
63
+ ##
64
+ # @return [String] The type of the component, either `component` or `gem`.
65
+ #
66
+ def type
67
+ settings.type
68
+ end
69
+
70
+ ##
71
+ # @return [String] The name of the component, e.g. the gem name.
72
+ #
73
+ def name
74
+ settings.name
75
+ end
76
+
77
+ ##
78
+ # Change the working directory to the component directory.
79
+ #
80
+ def cd(&block)
81
+ ::Dir.chdir(directory(from: :absolute), &block)
82
+ end
83
+
84
+ ##
85
+ # Returns the directory path. It can be returned either as a relative path
86
+ # from the context directory or an absolute path.
87
+ #
88
+ # @param from [:context,:absolute] From where (defaults to `:context`)
89
+ # @return [String] The directory path
90
+ #
91
+ def directory(from: :context)
92
+ case from
93
+ when :context
94
+ settings.directory
95
+ when :absolute
96
+ ::File.expand_path(settings.directory, @utils.context_directory)
97
+ else
98
+ raise ArgumentError, "Unknown from value: #{from.inspect}"
99
+ end
100
+ end
101
+
102
+ ##
103
+ # Returns the path to a given file. It can be returned as a relative path
104
+ # from the component directory, a relative path from the context
105
+ # directory, or an absolute path.
106
+ #
107
+ # @param from [:directory,:context,:absolute] From where (defaults to
108
+ # `:directory`)
109
+ # @return [String] The path to the file
110
+ #
111
+ def file_path(path, from: :directory)
112
+ case from
113
+ when :directory
114
+ path
115
+ when :context
116
+ ::File.join(directory, path)
117
+ when :absolute
118
+ ::File.expand_path(path, directory(from: :absolute))
119
+ else
120
+ raise ArgumentError, "Unknown from value: #{from.inspect}"
121
+ end
122
+ end
123
+
124
+ ##
125
+ # Returns the path to the changelog. It can be returned as a relative
126
+ # path from the component directory, a relative path from the context
127
+ # directory, or an absolute path.
128
+ #
129
+ # @param from [:directory,:context,:absolute] From where (defaults to
130
+ # `:directory`)
131
+ # @return [String] The path to the changelog
132
+ #
133
+ def changelog_path(from: :directory)
134
+ file_path(settings.changelog_path, from: from)
135
+ end
136
+
137
+ ##
138
+ # Returns the path to the version.rb. It can be returned as a relative
139
+ # path from the component directory, a relative path from the context
140
+ # directory, or an absolute path.
141
+ #
142
+ # @param from [:directory,:context,:absolute] From where (defaults to
143
+ # `:directory`)
144
+ # @return [String] The path to the `version.rb` file
145
+ #
146
+ def version_rb_path(from: :directory)
147
+ file_path(settings.version_rb_path, from: from)
148
+ end
149
+
150
+ ##
151
+ # Validates the component and reports any errors.
152
+ #
153
+ def validate
154
+ @utils.accumulate_errors("Component \"#{name}\" failed validation") do
155
+ path = directory(from: :absolute)
156
+ @utils.error("Missing directory #{path} for #{name}") unless ::File.directory?(path)
157
+ @utils.error("Missing changelog #{changelog_file.path} for #{name}") unless changelog_file.exists?
158
+ @utils.error("Missing version #{version_rb_file.path} for #{name}") unless version_rb_file.exists?
159
+ version_constant = settings.version_constant.join("::")
160
+ unless version_rb_file.eval_version
161
+ @utils.error("#{version_rb_file.path} for #{name} didn't define #{version_constant}")
162
+ end
163
+ yield if block_given?
164
+ end
165
+ end
166
+
167
+ ##
168
+ # Returns the version of the latest release tag on the given branch.
169
+ #
170
+ # @param ref [String] The branch name or head ref. Optional. Defaults to
171
+ # the current HEAD.
172
+ # @return [Gem::Version,nil] The version, or nil if no release tags found.
173
+ #
174
+ def latest_tag_version(ref: nil)
175
+ ref ||= "HEAD"
176
+ last_version = nil
177
+ @utils.capture(["git", "tag", "--merged", ref], e: true).split("\n").each do |tag|
178
+ match = %r{^#{name}/v(\d+\.\d+\.\d+(?:\.\w+)*)$}.match(tag)
179
+ next unless match
180
+ version = ::Gem::Version.new(match[1])
181
+ last_version = version if !last_version || version > last_version
182
+ end
183
+ last_version
184
+ end
185
+
186
+ ##
187
+ # Returns the latest release tag on the given branch.
188
+ #
189
+ # @param ref [String] The branch name or head ref. Optional. Defaults to
190
+ # the current HEAD.
191
+ # @return [String,nil] The tag, or nil if no release tags found.
192
+ #
193
+ def latest_tag(ref: nil)
194
+ version_tag(latest_tag_version(ref: ref))
195
+ end
196
+
197
+ ##
198
+ # Returns the tag for the given version.
199
+ #
200
+ # @param version [::Gem::Version,nil]
201
+ # @return [String] The tag, for a version
202
+ # @return [nil] if the given version is nil.
203
+ #
204
+ def version_tag(version)
205
+ version ? "#{name}/v#{version}" : nil
206
+ end
207
+
208
+ ##
209
+ # Gets the current version from the changelog.
210
+ #
211
+ # @param at [String,nil] An optional committish
212
+ # @return [::Gem::Version,nil] The current version
213
+ #
214
+ def current_changelog_version(at: nil)
215
+ if at
216
+ path = changelog_path(from: :context)
217
+ content = @utils.capture(["git", "show", "#{at}:#{path}"], e: true)
218
+ return ChangelogFile.current_version_from_content(content)
219
+ end
220
+ changelog_file.current_version
221
+ end
222
+
223
+ ##
224
+ # Gets the current version from the version constant.
225
+ #
226
+ # @param at [String,nil] An optional committish
227
+ # @return [::Gem::Version,nil] The current version
228
+ #
229
+ def current_constant_version(at: nil)
230
+ if at
231
+ path = version_rb_path(from: :context)
232
+ content = @utils.capture(["git", "show", "#{at}:#{path}"], e: true)
233
+ return VersionRbFile.current_version_from_content(content)
234
+ end
235
+ version_rb_file.current_version
236
+ end
237
+
238
+ ##
239
+ # Verify the given version matches the current version from the changelog
240
+ # and version constant. Reports any errors found.
241
+ #
242
+ # @param version [String,::Gem::Version] The claimed version
243
+ #
244
+ def verify_version(version)
245
+ @utils.accumulate_errors("Requested #{name} version #{version} doesn't match existing files.") do
246
+ changelog_version = changelog_file.current_version
247
+ if version.to_s != changelog_version.to_s
248
+ @utils.error("#{changelog_file.path} reports version #{changelog_version}.")
249
+ end
250
+ constant_version = version_rb_file.current_version
251
+ if version.to_s != constant_version.to_s
252
+ @utils.error("#{version_rb_file.path} reports version #{constant_version}.")
253
+ end
254
+ end
255
+ end
256
+
257
+ ##
258
+ # Returns a list of commit messages, since the given committish, that are
259
+ # relevant to this component.
260
+ #
261
+ # @param from [String,nil] The starting point, defaults to the last
262
+ # release tag. Set to nil explicitly to use the first commit.
263
+ # @param to [String] The endpoint. Defaults to HEAD.
264
+ # @return [ChangeSet]
265
+ #
266
+ def make_change_set(from: :default, to: nil)
267
+ to ||= "HEAD"
268
+ from = latest_tag(ref: to) if from == :default
269
+ commits = from ? "#{from}..#{to}" : to
270
+ changeset = ChangeSet.new(@repo_settings)
271
+ shas = @utils.capture(["git", "log", commits, "--format=%H"], e: true).split("\n")
272
+ shas.reverse_each do |sha|
273
+ message = touched_message(sha)
274
+ changeset.add_message(sha, message) if message
275
+ end
276
+ changeset.finish
277
+ end
278
+
279
+ ##
280
+ # Run bundler
281
+ #
282
+ def bundle
283
+ cd do
284
+ exec_result = @utils.exec(["bundle", "install"])
285
+ @utils.error("Bundle install failed for #{name}.") unless exec_result.success?
286
+ end
287
+ self
288
+ end
289
+
290
+ ##
291
+ # Checks if the given sha touches this component. If so, returns the
292
+ # commit message, otherwise returns nil.
293
+ #
294
+ # @return [String] if the given commit touches this component
295
+ # @return [nil] if the given commit does not touch this component
296
+ #
297
+ def touched_message(sha)
298
+ dir = settings.directory
299
+ dir = "#{dir}/" unless dir.end_with?("/")
300
+
301
+ message = @utils.capture(["git", "log", sha, "--max-count=1", "--format=%B"], e: true)
302
+ return message if dir == "./" || /(^|\n)touch-component: #{name}(\s|$)/i.match?(message)
303
+
304
+ result = @utils.exec(["git", "rev-parse", "#{sha}^"], out: :capture, err: :null)
305
+ parent_sha =
306
+ if result.success?
307
+ result.captured_out.strip
308
+ else
309
+ @utils.capture(["git", "hash-object", "-t", "tree", "--stdin"], in: :null).strip
310
+ end
311
+ files = @utils.capture(["git", "diff", "--name-only", "#{parent_sha}..#{sha}"], e: true)
312
+ files.split("\n").each do |file|
313
+ return message if (file.start_with?(dir) ||
314
+ settings.include_globs.any? { |pattern| ::File.fnmatch?(pattern, file) }) &&
315
+ settings.exclude_globs.none? { |pattern| ::File.fnmatch?(pattern, file) }
316
+ end
317
+ nil
318
+ end
319
+
320
+ # @private
321
+ attr_writer :coordination_group
322
+
323
+ # @private
324
+ def eql?(other)
325
+ name == other.name
326
+ end
327
+ alias == eql?
328
+
329
+ # @private
330
+ def hash
331
+ name.hash
332
+ end
333
+ end
334
+
335
+ ##
336
+ # Subclass for Gem components
337
+ #
338
+ class GemComponent < Component
339
+ ##
340
+ # Returns the path to the gemspec. It can be returned as a relative path
341
+ # from the component directory, a relative path from the context
342
+ # directory, or an absolute path.
343
+ #
344
+ # @param from [:directory,:context,:absolute] From where (defaults to
345
+ # `:directory`)
346
+ # @return [String] The path to the gemspec file
347
+ #
348
+ def gemspec_path(from: :directory)
349
+ file_path("#{name}.gemspec", from: from)
350
+ end
351
+
352
+ ##
353
+ # Validates the component and reports any errors.
354
+ # Includes both errors from the base class and gem-specific errors.
355
+ #
356
+ def validate
357
+ super do
358
+ path = gemspec_path(from: :absolute)
359
+ @utils.error("Missing gemspec #{path} for #{name}") unless ::File.file?(path)
360
+ end
361
+ end
362
+
363
+ ##
364
+ # Return a list of released versions
365
+ #
366
+ # @return [Array<::Gem::Version>]
367
+ #
368
+ def released_versions
369
+ content = @utils.capture(["gem", "info", "-r", "-a", name], e: true)
370
+ match = /#{name} \(([\w., ]+)\)/.match(content)
371
+ return [] unless match
372
+ match[1].split(/,\s+/).map { |str| ::Gem::Version.new(str) }
373
+ end
374
+
375
+ ##
376
+ # Determines if a version has been released
377
+ #
378
+ # @param version [::Gem::Version,String] The version to check
379
+ # @return [boolean] Whether the version has been released
380
+ #
381
+ def version_released?(version)
382
+ cmd = ["gem", "search", name, "--exact", "--remote", "--version", version.to_s]
383
+ content = @utils.capture(cmd)
384
+ content.include?("#{name} (#{version})")
385
+ end
386
+ end
387
+ end
388
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toys
4
+ module Release
5
+ ##
6
+ # An error raised by the release system
7
+ #
8
+ class ReleaseError < ::StandardError
9
+ ##
10
+ # Create a ReleaseError
11
+ # @private
12
+ #
13
+ def initialize(message, more_messages)
14
+ super(message)
15
+ @more_messages = more_messages
16
+ end
17
+
18
+ ##
19
+ # @return [Array<String>] Any secondary error messages
20
+ #
21
+ attr_reader :more_messages
22
+
23
+ ##
24
+ # @return [Array<String>] All messages including the primary and secondary
25
+ #
26
+ def all_messages
27
+ [message] + more_messages
28
+ end
29
+ end
30
+
31
+ ##
32
+ # Utilities for running release scripts
33
+ #
34
+ class EnvironmentUtils
35
+ ##
36
+ # Create script utilities
37
+ #
38
+ def initialize(tool_context,
39
+ in_github_action: nil,
40
+ on_error_option: nil)
41
+ @in_github_action = !::ENV["GITHUB_ACTIONS"].nil? if in_github_action.nil?
42
+ @on_error_option = on_error_option || :exit
43
+ @tool_context = tool_context
44
+ @logger = tool_context.logger
45
+ @error_list = nil
46
+ end
47
+
48
+ ##
49
+ # @return [Toys::Context] The Toys tool context
50
+ #
51
+ attr_reader :tool_context
52
+
53
+ ##
54
+ # @return [Logger] The current Toys logger
55
+ #
56
+ attr_reader :logger
57
+
58
+ ##
59
+ # @return [:nothing,:raise,:exit] What to do on error
60
+ #
61
+ attr_reader :on_error_option
62
+
63
+ ##
64
+ # @return [boolean] Whether we are running in a GitHub action
65
+ #
66
+ def in_github_action?
67
+ @in_github_action
68
+ end
69
+
70
+ ##
71
+ # @return [String] Absolute path to the context directory
72
+ #
73
+ def context_directory
74
+ tool_context.context_directory
75
+ end
76
+
77
+ ##
78
+ # Log a message at INFO level
79
+ #
80
+ # @param message [String] Message to log
81
+ #
82
+ def log(message)
83
+ logger.info(message)
84
+ end
85
+
86
+ ##
87
+ # Report a fatal error.
88
+ #
89
+ # @param message [String] Message to report
90
+ # @param more_messages [Array<String>] Additional secondary messages
91
+ #
92
+ def error(message, *more_messages)
93
+ if @error_list
94
+ @error_list << message
95
+ more_messages.each { |msg| @error_list << msg }
96
+ return
97
+ end
98
+ if in_github_action? && !::ENV["TOYS_RELEASER_TESTING"]
99
+ loc = caller_locations(1).first
100
+ puts("::error file=#{loc.path},line=#{loc.lineno}::#{message}")
101
+ else
102
+ tool_context.puts(message, :red, :bold)
103
+ end
104
+ more_messages.each { |m| tool_context.puts(m, :red) }
105
+ case on_error_option
106
+ when :raise
107
+ raise ReleaseError.new(message, more_messages)
108
+ when :exit
109
+ sleep(1) if in_github_action?
110
+ tool_context.exit(1)
111
+ end
112
+ end
113
+
114
+ ##
115
+ # Accumulate any errors within the block. If any were present, then
116
+ # emit them all at once, prefaced with the given main message.
117
+ #
118
+ # @param main_message [String,nil] An initial message to emit if there are
119
+ # errors. Omit if nil.
120
+ #
121
+ def accumulate_errors(main_message = nil)
122
+ previous_list = @error_list
123
+ @error_list = []
124
+ result = yield
125
+ current_list = @error_list
126
+ @error_list = previous_list
127
+ unless current_list.empty?
128
+ current_list.unshift(main_message) if main_message
129
+ error(*current_list)
130
+ end
131
+ result
132
+ end
133
+
134
+ ##
135
+ # Accumulate any errors within the block and return the messages instead
136
+ # of raising an exception or exiting. If no errors happened, returns the
137
+ # empty array.
138
+ #
139
+ # @param errors [Array<String>,nil] If an array, append any errors to it
140
+ # in place, otherwise execute the block and let errors bubble through.
141
+ # @return [Object] The block's result if success
142
+ # @return [nil] if errors happened
143
+ #
144
+ def capture_errors(errors = nil)
145
+ return yield unless errors
146
+ previous_option = on_error_option
147
+ @on_error_option = :raise
148
+ yield
149
+ rescue ReleaseError => e
150
+ errors.concat(e.all_messages)
151
+ nil
152
+ ensure
153
+ @on_error_option = previous_option
154
+ end
155
+
156
+ ##
157
+ # Report a recoverable warning.
158
+ #
159
+ # @param message [String] Message to report
160
+ # @param more_messages [Array<String>] Additional secondary messages
161
+ #
162
+ def warning(message, *more_messages)
163
+ if in_github_action? && !::ENV["TOYS_RELEASER_TESTING"]
164
+ loc = caller_locations(1).first
165
+ puts("::warning file=#{loc.path},line=#{loc.lineno}::#{message}")
166
+ else
167
+ tool_context.puts(message, :yellow, :bold)
168
+ end
169
+ more_messages.each { |m| tool_context.puts(m, :yellow) }
170
+ end
171
+
172
+ ##
173
+ # Run an external command.
174
+ #
175
+ # @param cmd [Array<String>] The command
176
+ # @param opts [Hash] Extra options
177
+ # @return [Toys::Utils::Exec::Result]
178
+ #
179
+ def exec(cmd, **opts, &block)
180
+ modify_exec_opts(opts, cmd)
181
+ tool_context.exec(cmd, **opts, &block)
182
+ end
183
+
184
+ ##
185
+ # Run an external command and return its output.
186
+ #
187
+ # @param cmd [Array<String>] The command
188
+ # @param opts [Hash] Extra options
189
+ # @return [String] The output
190
+ #
191
+ def capture(cmd, **opts, &block)
192
+ modify_exec_opts(opts, cmd)
193
+ tool_context.capture(cmd, **opts, &block)
194
+ end
195
+
196
+ ##
197
+ # Run an external Ruby script.
198
+ #
199
+ # @param code [String] The Ruby code
200
+ # @param opts [Hash] Extra options
201
+ # @return [Toys::Utils::Exec::Result]
202
+ #
203
+ def ruby(code, **opts, &block)
204
+ opts[:in] = [:string, code]
205
+ modify_exec_opts(opts, "ruby")
206
+ tool_context.ruby([], **opts, &block)
207
+ end
208
+
209
+ ##
210
+ # Run an external Ruby script and return its output.
211
+ #
212
+ # @param code [String] The Ruby code
213
+ # @param opts [Hash] Extra options
214
+ # @return [String] The output
215
+ #
216
+ def capture_ruby(code, **opts, &block)
217
+ opts[:in] = [:string, code]
218
+ modify_exec_opts(opts, "ruby")
219
+ tool_context.capture_ruby([], **opts, &block)
220
+ end
221
+
222
+ ##
223
+ # Run an external toys tool.
224
+ #
225
+ # @param cmd [Array<String>] The tool and its parameters
226
+ # @param opts [Hash] Extra options
227
+ # @return [Toys::Utils::Exec::Result]
228
+ #
229
+ def exec_separate_tool(cmd, **opts, &block)
230
+ modify_exec_opts(opts, cmd)
231
+ tool_context.exec_separate_tool(cmd, **opts, &block)
232
+ end
233
+
234
+ private
235
+
236
+ def modify_exec_opts(opts, cmd)
237
+ if opts.delete(:e) || opts.delete(:exit_on_nonzero_status)
238
+ opts[:result_callback] ||=
239
+ proc do |r|
240
+ error("Command failed with exit code #{r.exit_code}: #{cmd.inspect}") if r.error?
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end