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,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ require_relative "artifact_dir"
7
+ require_relative "steps"
8
+
9
+ module Toys
10
+ module Release
11
+ ##
12
+ # Performs releases
13
+ #
14
+ class Performer
15
+ ##
16
+ # Results to report for a component release
17
+ #
18
+ class Result
19
+ ##
20
+ # Create a new result
21
+ #
22
+ # @param component_name [String] The name of the component
23
+ # @param version [::Gem::Version] The version to release
24
+ #
25
+ def initialize(component_name, version)
26
+ @component_name = component_name
27
+ @version = version
28
+ @successes = []
29
+ @errors = []
30
+ end
31
+
32
+ ##
33
+ # @return [boolean] Whether the result is capturing errors
34
+ #
35
+ def capture_errors?
36
+ !@errors.nil?
37
+ end
38
+
39
+ ##
40
+ # @return [boolean] True if there is no content (neither success nor
41
+ # error) for this result.
42
+ #
43
+ def empty?
44
+ successes.empty? && errors.empty?
45
+ end
46
+
47
+ ##
48
+ # @return [String] The name of the component
49
+ #
50
+ attr_reader :component_name
51
+
52
+ ##
53
+ # @return [::Gem::Version] The version to release
54
+ #
55
+ attr_reader :version
56
+
57
+ ##
58
+ # @return [Array<String>] The success messages
59
+ #
60
+ attr_reader :successes
61
+
62
+ ##
63
+ # @return [Array<String>] The error messages
64
+ #
65
+ attr_reader :errors
66
+
67
+ ##
68
+ # @return [Array<String>] The success messages, formatted for output.
69
+ #
70
+ def formatted_successes
71
+ successes.map { |line| "* #{line}" }
72
+ end
73
+
74
+ ##
75
+ # @return [Array<String>] The error messages, formatted for output.
76
+ # Returns the empty array if this Result is not capturing errors.
77
+ #
78
+ def formatted_errors
79
+ errors.map { |line| "* ERROR: #{line}" }
80
+ end
81
+
82
+ # @private
83
+ attr_writer :version
84
+ end
85
+
86
+ ##
87
+ # Create a release performer.
88
+ #
89
+ # @param repository [Repository]
90
+ # @param release_ref [String] Git ref or SHA for the release.
91
+ # @param release_pr [Integer,PullRequest] Pull request for the release.
92
+ # @param enable_prechecks [boolean]
93
+ # @param git_remote [String] Git remote.
94
+ # @param work_dir [String,nil] Directory for temporary artifacts. Uses an
95
+ # ephemeral temporary directory if not provided.
96
+ # @param dry_run [boolean] If true, doesn't perform permanent operations
97
+ # such as pushing gems or creating github releases.
98
+ #
99
+ def initialize(repository,
100
+ release_ref: nil,
101
+ release_pr: nil,
102
+ enable_prechecks: true,
103
+ git_remote: nil,
104
+ work_dir: nil,
105
+ dry_run: false)
106
+ @enable_prechecks = enable_prechecks
107
+ @repository = repository
108
+ @settings = repository.settings
109
+ @utils = repository.utils
110
+ @dry_run = dry_run
111
+ @gh_token = ENV["GITHUB_TOKEN"]
112
+ @git_remote = git_remote || "origin"
113
+ @work_dir = work_dir
114
+ @release_sha = @pull_request = @pr_components = nil
115
+ @component_results = []
116
+ @init_result = Result.new(nil, nil)
117
+ @start_time = ::Time.now.utc
118
+ @utils.capture_errors(@init_result.errors) do
119
+ resolve_ref_and_pr(release_ref, release_pr)
120
+ repo_prechecks if @enable_prechecks
121
+ end
122
+ end
123
+
124
+ ##
125
+ # @return [PullRequest] the GitHub pull request
126
+ # @return [nil] if this performer was not configured with a pull request
127
+ #
128
+ attr_reader :pull_request
129
+
130
+ ##
131
+ # @return [Result] the results of initialization and prechecks
132
+ #
133
+ attr_reader :init_result
134
+
135
+ ##
136
+ # @return [Array<Result>] the results of the various releases done
137
+ #
138
+ attr_reader :component_results
139
+
140
+ ##
141
+ # Perform a release without pull request direction. Stores the result in a
142
+ # new result appended to the component results.
143
+ #
144
+ # @param component_name [String] the name of the component to release
145
+ # @param assert_version [String] (optional) if provided, asserts that the
146
+ # released version is the same as what is given
147
+ #
148
+ def perform_adhoc_release(component_name, assert_version: nil)
149
+ @repository.at_sha(@release_sha) do
150
+ result = Result.new(component_name, assert_version)
151
+ @component_results << result
152
+ @utils.capture_errors(result.errors) do
153
+ component = @repository.component_named(component_name)
154
+ version = component.current_changelog_version
155
+ if !component
156
+ @utils.error("Component #{component_name.inspect} not found.")
157
+ elsif assert_version && assert_version != version
158
+ @utils.error("Asserted version #{assert_version} does not match version " \
159
+ "#{version} found in the changelog for #{component_name.inspect}.")
160
+ else
161
+ result.version = version
162
+ internal_perform_release(component, version, result)
163
+ end
164
+ end
165
+ end
166
+ self
167
+ end
168
+
169
+ ##
170
+ # Perform all releases associated with the configured pull request
171
+ #
172
+ def perform_pr_releases
173
+ if @pr_components.nil?
174
+ @utils.capture_errors(@init_result.errors) do
175
+ @utils.error("Cannot perform PR releases because no pull request was found.")
176
+ end
177
+ return self
178
+ end
179
+ @pr_components.each do |component_name, version|
180
+ perform_adhoc_release(component_name, assert_version: version)
181
+ end
182
+ self
183
+ end
184
+
185
+ ##
186
+ # Returns true if any errors happened in any of the releases
187
+ #
188
+ # @return [boolean]
189
+ #
190
+ def error?
191
+ !@init_result.errors.empty? || @component_results.any? { |result| !result.errors.empty? }
192
+ end
193
+
194
+ ##
195
+ # @return [String] The pull request URL
196
+ # @return [nil] if there is no pull request configured for this performer
197
+ #
198
+ def pr_url
199
+ @pull_request&.url
200
+ end
201
+
202
+ ##
203
+ # Updates the pull request (if any) with the release results.
204
+ # Also opens an issue if any failures happened.
205
+ #
206
+ def report_results
207
+ report_text = build_report_text
208
+ if @pull_request
209
+ @utils.log("Updating release pull request #{@pull_request.url} ...")
210
+ label = error? ? @settings.release_error_label : @settings.release_complete_label
211
+ @pull_request.update(labels: label)
212
+ @pull_request.add_comment(report_text)
213
+ @utils.log("Updated release pull request #{@pull_request.url}")
214
+ end
215
+ if error?
216
+ @utils.log("Opening a new issue to report the failure ...")
217
+ body = <<~STR
218
+ A release job failed.
219
+
220
+ Release PR: #{@pull_request&.url || 'unknown'}
221
+ Commit: https://github.com/#{@settings.repo_path}/commit/#{@release_sha}
222
+
223
+ ----
224
+
225
+ #{report_text}
226
+ STR
227
+ title = "Release PR ##{@pull_request&.number || 'unknown'} failed with errors"
228
+ issue_number = @repository.open_issue(title, body)["number"]
229
+ @utils.log("Issue ##{issue_number} opened")
230
+ end
231
+ self
232
+ end
233
+
234
+ ##
235
+ # Builds a report of the release results
236
+ #
237
+ # @return [String]
238
+ #
239
+ def build_report_text
240
+ lines = [
241
+ "## Release job results",
242
+ "",
243
+ ]
244
+ lines.concat(main_report_lines)
245
+ unless @init_result.empty?
246
+ lines << ""
247
+ lines << "### Setup"
248
+ lines << ""
249
+ lines.concat(@init_result.formatted_errors)
250
+ lines.concat(@init_result.formatted_successes)
251
+ end
252
+ @component_results.each do |result|
253
+ next if result.empty?
254
+ lines << ""
255
+ lines << "### #{result.component_name} #{result.version}"
256
+ lines << ""
257
+ lines.concat(result.formatted_errors)
258
+ lines.concat(result.formatted_successes)
259
+ end
260
+ lines.join("\n")
261
+ end
262
+
263
+ private
264
+
265
+ def main_report_lines
266
+ lines = [
267
+ "* Job started #{@start_time.strftime('%Y-%m-%d %H:%M:%S')} UTC",
268
+ "* Job finished #{::Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')} UTC",
269
+ ]
270
+ lines << "* Release SHA: #{@release_sha}" if @release_sha
271
+ lines << "* Release pull request: #{@pull_request.url}" if @pull_request
272
+ lines << if error?
273
+ "* **Release job completed with errors.**"
274
+ else
275
+ "* **All releases completed successfully.**"
276
+ end
277
+ if (server_url = ::ENV["GITHUB_SERVER_URL"])
278
+ if (repo = ::ENV["GITHUB_REPOSITORY"])
279
+ if (run_id = ::ENV["GITHUB_RUN_ID"])
280
+ lines << "* Run logs: #{server_url}/#{repo}/actions/runs/#{run_id}"
281
+ end
282
+ end
283
+ end
284
+ lines
285
+ end
286
+
287
+ def resolve_ref_and_pr(ref, pr_info)
288
+ @pull_request = nil
289
+ case pr_info
290
+ when ::Integer, ::String
291
+ @pull_request = @repository.load_pr(pr_info.to_i)
292
+ @utils.error("Pull request number #{pr_info} not found.") if @pull_request.nil?
293
+ when PullRequest
294
+ @pull_request = pr_info
295
+ end
296
+ ref = @pull_request.merge_commit_sha if @pull_request && !ref
297
+ @release_sha = @repository.current_sha(ref)
298
+ @utils.log("Release SHA set to #{@release_sha}")
299
+ if @pull_request
300
+ @utils.log("Release pull request is #{@pull_request.url}")
301
+ else
302
+ @utils.warning("Pull request not provided, and will not be updated with the release info.")
303
+ end
304
+ @pr_components = @pull_request ? @repository.released_components_and_versions(@pull_request) : nil
305
+ @utils.exec(["git", "fetch", @git_remote, @release_sha], e: true)
306
+ self
307
+ end
308
+
309
+ def repo_prechecks
310
+ @utils.log("Performing repo-level prechecks ...")
311
+ @repository.verify_git_clean
312
+ @repository.verify_repo_identity(remote: @git_remote)
313
+ @repository.verify_github_checks(ref: @release_sha)
314
+ @utils.log("Repo-level prechecks succeeded.")
315
+ self
316
+ end
317
+
318
+ def internal_perform_release(component, version, result)
319
+ component_prechecks(component, version) if @enable_prechecks
320
+ artifact_dir = Toys::Release::ArtifactDir.new(@work_dir)
321
+ begin
322
+ component.cd do
323
+ component.settings.steps.each do |step_settings|
324
+ result_code = Toys::Release::Steps.run(
325
+ type: step_settings.type, name: step_settings.name, options: step_settings.options,
326
+ repository: @repository, component: component, version: version, performer_result: result,
327
+ artifact_dir: artifact_dir, dry_run: @dry_run, git_remote: @git_remote
328
+ )
329
+ break if result_code == :abort
330
+ end
331
+ end
332
+ ensure
333
+ artifact_dir.cleanup
334
+ end
335
+ self
336
+ end
337
+
338
+ def component_prechecks(component, version)
339
+ @utils.log("Running prechecks for #{component.name.inspect} ...")
340
+ component.verify_version(version)
341
+ @utils.log("Completed prechecks for #{component.name.inspect}")
342
+ self
343
+ end
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "semver"
6
+
7
+ module Toys
8
+ module Release
9
+ ##
10
+ # Represents a release pull request
11
+ #
12
+ class PullRequest
13
+ ##
14
+ # Create a pull request object
15
+ #
16
+ # @param repository [Repository]
17
+ # @param resource [Hash] The resource hash describing the pull request
18
+ #
19
+ def initialize(repository, resource)
20
+ @repository = repository
21
+ @resource = resource
22
+ end
23
+
24
+ ##
25
+ # @return [Repository]
26
+ #
27
+ attr_reader :repository
28
+
29
+ ##
30
+ # @return [Hash] The resource hash describing the pull request
31
+ #
32
+ attr_reader :resource
33
+
34
+ ##
35
+ # @return [Integer] The pull request number
36
+ #
37
+ def number
38
+ resource["number"].to_i
39
+ end
40
+
41
+ ##
42
+ # @return [String] The pull request title
43
+ #
44
+ def title
45
+ resource["title"]
46
+ end
47
+
48
+ ##
49
+ # @return [String] The pull request state
50
+ #
51
+ def state
52
+ resource["state"]
53
+ end
54
+
55
+ ##
56
+ # @return [Array<String>] The current label names
57
+ #
58
+ def labels
59
+ resource["labels"].map { |label_info| label_info["name"] }
60
+ end
61
+
62
+ ##
63
+ # @return [String] The pull request URL
64
+ #
65
+ def url
66
+ "https://github.com/#{repository.settings.repo_path}/pull/#{number}"
67
+ end
68
+
69
+ ##
70
+ # @return [String] The SHA of the merge commit
71
+ #
72
+ def merge_commit_sha
73
+ resource["merge_commit_sha"]
74
+ end
75
+
76
+ ##
77
+ # @return [String] The SHA of the pull request head
78
+ #
79
+ def head_sha
80
+ resource["head"]["sha"]
81
+ end
82
+
83
+ ##
84
+ # @return [String] The SHA of the pull request base
85
+ #
86
+ def base_sha
87
+ resource["base"]["sha"]
88
+ end
89
+
90
+ ##
91
+ # @return [String] The ref of the pull request head
92
+ #
93
+ def head_ref
94
+ resource["head"]["ref"]
95
+ end
96
+
97
+ ##
98
+ # @return [String] The ref of the pull request base
99
+ #
100
+ def base_ref
101
+ resource["base"]["ref"]
102
+ end
103
+
104
+ ##
105
+ # @return [boolean] Whether the pull request has been merged
106
+ #
107
+ def merged?
108
+ resource["merged_at"] ? true : false
109
+ end
110
+
111
+ ##
112
+ # Perform various updates to a pull request
113
+ #
114
+ # @param labels [String,Array<String>,nil] One or more release-related
115
+ # labels that should be applied. All existing release-related labels
116
+ # are replaced with this list. Optional; no label updates are applied
117
+ # if not present.
118
+ # @param state [String,nil] New pull request state. Optional; the state is
119
+ # not modified if not present.
120
+ # @return [self]
121
+ #
122
+ def update(labels: nil, state: nil)
123
+ body = {}
124
+ body[:state] = state if state && self.state != state
125
+ if labels
126
+ labels = Array(labels)
127
+ release_labels, other_labels = self.labels.partition do |label|
128
+ repository.release_related_label?(label)
129
+ end
130
+ body[:labels] = other_labels + labels unless release_labels.sort == labels.sort
131
+ end
132
+ unless body.empty?
133
+ cmd = ["gh", "api", "-XPATCH", "repos/#{repository.settings.repo_path}/issues/#{number}",
134
+ "--input", "-", "-H", "Accept: application/vnd.github.v3+json"]
135
+ repository.utils.exec(cmd, in: [:string, ::JSON.dump(body)], out: :null, e: true)
136
+ end
137
+ self
138
+ end
139
+
140
+ ##
141
+ # Add a comment to a pull request
142
+ #
143
+ # @param message [String] A comment to add to the pull request.
144
+ # @return [self]
145
+ #
146
+ def add_comment(message)
147
+ cmd = ["gh", "api", "repos/#{repository.settings.repo_path}/issues/#{number}/comments",
148
+ "--input", "-", "-H", "Accept: application/vnd.github.v3+json"]
149
+ repository.utils.exec(cmd, in: [:string, ::JSON.dump(body: message)], out: :null, e: true)
150
+ self
151
+ end
152
+ end
153
+ end
154
+ end