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,661 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "fileutils"
5
+ require "json"
6
+ require "tmpdir"
7
+ require "yaml"
8
+
9
+ require_relative "component"
10
+ require_relative "pull_request"
11
+
12
+ module Toys
13
+ module Release
14
+ ##
15
+ # Represents a repository in the release system
16
+ #
17
+ class Repository
18
+ ##
19
+ # Create a repository
20
+ #
21
+ # @param environment_utils [Toys::Release::EnvrionmentUtils]
22
+ # @param settings [Toys::Release::RepoSettings]
23
+ #
24
+ def initialize(environment_utils, settings)
25
+ @utils = environment_utils
26
+ @settings = settings
27
+ build_components
28
+ ensure_gh_binary
29
+ ensure_git_binary
30
+ end
31
+
32
+ ##
33
+ # @return [Toys::Release::RepoSettings] The repo settings
34
+ #
35
+ attr_reader :settings
36
+
37
+ ##
38
+ # @return [Toys::Release::EnvironmentUtils] The environment utils
39
+ #
40
+ attr_reader :utils
41
+
42
+ ##
43
+ # @return [Array<Array<Toys::Release::Component>>] All coordination
44
+ # groups
45
+ #
46
+ attr_reader :coordination_groups
47
+
48
+ ##
49
+ # @return [Toys::Release::Component,nil] The component for the given name,
50
+ # or nil if the name is not known.
51
+ #
52
+ def component_named(name)
53
+ @components[name]
54
+ end
55
+
56
+ ##
57
+ # @return [Array<Toys::Release::Component>] All components
58
+ #
59
+ def all_components
60
+ @components.values
61
+ end
62
+
63
+ ##
64
+ # @return [String] The name of the release branch for a given component
65
+ # name
66
+ #
67
+ def release_branch_name(from_branch, component_name)
68
+ "#{settings.release_branch_prefix}/component/#{component_name}/#{from_branch}"
69
+ end
70
+
71
+ ##
72
+ # @return [String] A unique branch name for a multi-release
73
+ #
74
+ def multi_release_branch_name(from_branch)
75
+ timestamp = ::Time.now.strftime("%Y%m%d%H%M%S")
76
+ salt = format("%06d", rand(1_000_000))
77
+ "#{settings.release_branch_prefix}/multi/#{timestamp}-#{salt}/#{from_branch}"
78
+ end
79
+
80
+ ##
81
+ # @return [boolean] Whether the given branch name is release-related
82
+ #
83
+ def release_related_branch?(ref)
84
+ %r{^#{settings.release_branch_prefix}/(multi/\d{14}-\d{6}|component/[\w-]+)/[\w/-]+$}.match?(ref)
85
+ end
86
+
87
+ ##
88
+ # @return [boolean] Whether the given label name is release-related
89
+ #
90
+ def release_related_label?(name)
91
+ [
92
+ settings.release_pending_label,
93
+ settings.release_error_label,
94
+ settings.release_aborted_label,
95
+ settings.release_complete_label,
96
+ ].include?(name)
97
+ end
98
+
99
+ ##
100
+ # Return the SHA of the given ref
101
+ #
102
+ # @param ref [String,nil] Optional ref. Defaults to HEAD.
103
+ # @return [String] the SHA
104
+ #
105
+ def current_sha(ref = nil)
106
+ @utils.capture(["git", "rev-parse", ref || "HEAD"], e: true).strip
107
+ end
108
+
109
+ ##
110
+ # Return the current branch
111
+ #
112
+ # @return [String,nil] the branch name, or nil if no branch is checked out
113
+ #
114
+ def current_branch
115
+ branch = @utils.capture(["git", "branch", "--show-current"], e: true).strip
116
+ branch.empty? ? nil : branch
117
+ end
118
+
119
+ ##
120
+ # Return the url of the given git remote
121
+ #
122
+ # @param remote [String] The name of the remote
123
+ # @return [String] the URL of the remote
124
+ #
125
+ def git_remote_url(remote)
126
+ @utils.capture(["git", "remote", "get-url", remote], e: true).strip
127
+ end
128
+
129
+ ##
130
+ # Searches for existing open release pull requests
131
+ #
132
+ # @param branch [String,nil] Optional branch the releases would merge
133
+ # into. If not specified, gets releases for all branches.
134
+ #
135
+ # @return [Array<PullRequest>] Array of matching pull requests
136
+ #
137
+ def find_release_prs(branch: nil)
138
+ args = {
139
+ sort: "updated",
140
+ direction: "desc",
141
+ per_page: 64,
142
+ }
143
+ args[:base] = branch if branch
144
+ query = args.map { |k, v| "#{k}=#{v}" }.join("&")
145
+ output = @utils.capture(["gh", "api", "repos/#{settings.repo_path}/pulls?#{query}", "--paginate", "--slurp",
146
+ "-H", "Accept: application/vnd.github.v3+json"], e: true)
147
+ prs = ::JSON.parse(output).flatten(1)
148
+ release_label = settings.release_pending_label
149
+ prs = prs.find_all { |pr| pr["labels"].any? { |label| label["name"] == release_label } }
150
+ prs.map { |pr| PullRequest.new(self, pr) }
151
+ end
152
+
153
+ ##
154
+ # Load a pull request by number
155
+ #
156
+ # @param pr_number [String,Integer] Pull request number
157
+ # @return [PullRequest,nil] Pull request info, or nil if not found
158
+ #
159
+ def load_pr(pr_number)
160
+ result = @utils.exec(["gh", "api", "repos/#{settings.repo_path}/pulls/#{pr_number}",
161
+ "-H", "Accept: application/vnd.github.v3+json"],
162
+ out: :capture)
163
+ return nil unless result.success?
164
+ PullRequest.new(self, ::JSON.parse(result.captured_out))
165
+ end
166
+
167
+ ##
168
+ # Open a GitHub issue
169
+ #
170
+ # @param title [String] The issue title
171
+ # @param body [String] The issue body
172
+ # @return [Hash] The issue resource
173
+ #
174
+ def open_issue(title, body)
175
+ input = ::JSON.dump(title: title, body: body)
176
+ cmd = [
177
+ "gh", "api", "repos/#{settings.repo_path}/issues",
178
+ "--input", "-",
179
+ "-H", "Accept: application/vnd.github.v3+json"
180
+ ]
181
+ response = @utils.capture(cmd, in: [:string, input], e: true)
182
+ ::JSON.parse(response)
183
+ end
184
+
185
+ ##
186
+ # Verify that the given git remote points at the correct repo.
187
+ # Raises errors if not.
188
+ #
189
+ # @param remote [String] The remote name. Defaults to `origin`.
190
+ # @return [String] The repo in `owner/repo` form
191
+ #
192
+ def verify_repo_identity(remote: "origin")
193
+ @utils.log("Verifying git repo identity ...")
194
+ url = git_remote_url(remote)
195
+ cur_repo =
196
+ case url
197
+ when %r{^git@github.com:(?<git_repo>[^/]+/[^/]+)\.git$}
198
+ ::Regexp.last_match[:git_repo]
199
+ when %r{^https://github.com/(?<http_repo>[^/]+/[^/.]+)(?:/|\.git)?$}
200
+ ::Regexp.last_match[:http_repo]
201
+ else
202
+ @utils.error("Unrecognized remote url: #{url.inspect}")
203
+ end
204
+ if cur_repo == settings.repo_path
205
+ @utils.log("Git repo is correct.")
206
+ else
207
+ @utils.error("Remote repo is #{cur_repo}, expected #{settings.repo_path}")
208
+ end
209
+ cur_repo
210
+ end
211
+
212
+ ##
213
+ # @return [boolean] Whether the current git checkout is clean
214
+ #
215
+ def git_clean?
216
+ @utils.capture(["git", "status", "-s"], e: true).strip.empty?
217
+ end
218
+
219
+ ##
220
+ # Verify that the git checkout is clean.
221
+ # Raises errors if not.
222
+ #
223
+ def verify_git_clean
224
+ if git_clean?
225
+ @utils.log("Git working directory verified as clean.")
226
+ else
227
+ @utils.error("There are local git changes that are not committed.")
228
+ end
229
+ self
230
+ end
231
+
232
+ ##
233
+ # Verify that github checks have succeeded.
234
+ # Raises errors if not.
235
+ #
236
+ # @param ref [String,nil] The ref to check. Optional, defaults to HEAD.
237
+ #
238
+ def verify_github_checks(ref: nil)
239
+ if @settings.required_checks_regexp.nil?
240
+ @utils.log("GitHub checks disabled")
241
+ return self
242
+ end
243
+ ref = current_sha(ref)
244
+ @utils.log("Verifying GitHub checks ...")
245
+ errors = github_check_errors(ref)
246
+ @utils.error(*errors) unless errors.empty?
247
+ @utils.log("GitHub checks all passed.")
248
+ self
249
+ end
250
+
251
+ ##
252
+ # Wait until github checks have finished.
253
+ # Returns a set of errors or the empty array if succeeded.
254
+ #
255
+ # @param ref [String,nil] The ref to check. Optional, defaults to HEAD.
256
+ # @return [Array<String>] Errors
257
+ #
258
+ def wait_github_checks(ref: nil)
259
+ if @settings.required_checks_regexp.nil?
260
+ @utils.log("GitHub checks disabled")
261
+ return self
262
+ end
263
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @settings.required_checks_timeout
264
+ wait_github_checks_internal(current_sha(ref), deadline)
265
+ end
266
+
267
+ ##
268
+ # Ensure that the git user name and email are set.
269
+ #
270
+ def git_set_user_info
271
+ if @settings.git_user_name
272
+ unless @utils.exec(["git", "config", "--get", "user.name"], out: :null).success?
273
+ @utils.exec(["git", "config", "--local", "user.name", @settings.git_user_name], e: true)
274
+ end
275
+ end
276
+ if @settings.git_user_email
277
+ unless @utils.exec(["git", "config", "--get", "user.email"], out: :null).success?
278
+ @utils.exec(["git", "config", "--local", "user.email", @settings.git_user_email], e: true)
279
+ end
280
+ end
281
+ self
282
+ end
283
+
284
+ ##
285
+ # Fetch the repository history and tags for the given ref
286
+ #
287
+ # @param remote [String] The remote to fetch from.
288
+ # @param branch [String,nil] The head branch to fetch, or nil to use the
289
+ # current branch.
290
+ # @return [String] The actual branch
291
+ #
292
+ def git_prepare_branch(remote, branch: nil)
293
+ branch = simplify_branch_name(branch)
294
+ git_unshallow(remote, branch: branch)
295
+ @utils.exec(["git", "fetch", remote, "--tags"], e: true)
296
+ if branch && branch != current_branch
297
+ @utils.exec(["git", "switch", branch], e: true)
298
+ branch
299
+ else
300
+ current_branch
301
+ end
302
+ end
303
+
304
+ ##
305
+ # Ensure the given branch is fully fetched including all history
306
+ #
307
+ # @param remote [String] The remote to fetch from.
308
+ # @param branch [String,nil] The head branch to fetch, or nil to use the
309
+ # current branch.
310
+ # @return [boolean] Whether commits needed to be fetched.
311
+ #
312
+ def git_unshallow(remote, branch: nil)
313
+ if @utils.capture(["git", "rev-parse", "--is-shallow-repository"], e: true).strip == "true"
314
+ @utils.exec(["git", "fetch", "--unshallow", remote, branch || "HEAD"], e: true)
315
+ true
316
+ else
317
+ false
318
+ end
319
+ end
320
+
321
+ ##
322
+ # Simplify a branch name. If a ref of the form "refs/heads/my-branch" is
323
+ # given, the branch name is extracted.
324
+ #
325
+ # @param branch [String,nil] input ref
326
+ # @return [String,nil] normalized branch name
327
+ #
328
+ def simplify_branch_name(branch)
329
+ return if branch.nil?
330
+ match = %r{^refs/heads/([^/\s]+)$}.match(branch)
331
+ return match[1] if match
332
+ branch
333
+ end
334
+
335
+ ##
336
+ # Returns what components and versions are being released in the pull
337
+ # request
338
+ #
339
+ # @param pull [PullRequest] pull request
340
+ # @return [Hash{String=>String}] Map of component names to versions
341
+ #
342
+ def released_components_and_versions(pull)
343
+ single_released_component_and_version(pull) || multiple_released_components_and_versions(pull)
344
+ end
345
+
346
+ ##
347
+ # Switch to the given SHA temporarily and execute the given block.
348
+ #
349
+ # @param sha [String] The SHA to switch to
350
+ # @return [Object] Whatever the block returns
351
+ #
352
+ def at_sha(sha, quiet: false)
353
+ out = quiet ? :null : :inherit
354
+ original_branch = current_branch
355
+ original_sha = current_sha
356
+ if sha != original_sha
357
+ @utils.exec(["git", "switch", "--detach", sha], out: out, err: out, e: true)
358
+ end
359
+ yield
360
+ ensure
361
+ if sha != original_sha
362
+ if original_branch
363
+ @utils.exec(["git", "switch", original_branch], out: out, err: out, e: true)
364
+ else
365
+ @utils.exec(["git", "switch", "--detach", original_sha], out: out, err: out, e: true)
366
+ end
367
+ end
368
+ end
369
+
370
+ ##
371
+ # Returns the commit message for the given ref
372
+ #
373
+ # @param ref [String] Git ref. Defaults to "HEAD" if not provided.
374
+ # @return [String] The full commit message
375
+ #
376
+ def last_commit_message(ref: nil)
377
+ ref ||= "HEAD"
378
+ @utils.capture(["git", "log", ref, "--max-count=1", "--format=%B"], e: true).strip
379
+ end
380
+
381
+ ##
382
+ # Create and switch to a new branch. Deletes and overwrites any existing
383
+ # branch of that name.
384
+ #
385
+ # @param branch [String] Name for the branch
386
+ #
387
+ def create_branch(branch, quiet: false)
388
+ out = quiet ? :null : :inherit
389
+ if current_branch == branch
390
+ @utils.exec(["git", "switch", settings.main_branch], out: out, err: out, e: true)
391
+ end
392
+ if @utils.exec(["git", "rev-parse", "--verify", "--quiet", branch], out: :null).success?
393
+ @utils.warning("Branch #{branch} already exists. Deleting it.")
394
+ @utils.exec(["git", "branch", "-D", branch], out: out, err: out, e: true)
395
+ end
396
+ @utils.exec(["git", "switch", "-c", branch], out: out, err: out, e: true)
397
+ self
398
+ end
399
+
400
+ ##
401
+ # Commit the current changes.
402
+ #
403
+ # @param commit_title [String] Title for the commit
404
+ # @param commit_details [String] Multi-line commit details
405
+ # @param signoff [boolean] Whether to sign off
406
+ #
407
+ def git_commit(commit_title,
408
+ commit_details: nil,
409
+ signoff: false)
410
+ @utils.exec(["git", "add", "."], e: true)
411
+ commit_cmd = ["git", "commit", "-a", "-m", commit_title]
412
+ commit_cmd << "-m" << commit_details if commit_details
413
+ commit_cmd << "--signoff" if signoff
414
+ @utils.exec(commit_cmd, e: true)
415
+ self
416
+ end
417
+
418
+ ##
419
+ # Create a pull request for the current branch.
420
+ #
421
+ # @param base_branch [String] Base branch. Defaults to the main branch.
422
+ # @param remote [String] Name of the git remote. Defaults to "origin".
423
+ # @param title [String] Pull request title. Defaults to the last commit
424
+ # message.
425
+ # @param body [String] Pull request body. Defaults to empty.
426
+ # @param labels [Array<String>] Any labels to apply. Defaults to none.
427
+ # @return [PullRequest] Pull request resource.
428
+ #
429
+ def create_pull_request(base_branch: nil,
430
+ remote: nil,
431
+ title: nil,
432
+ body: nil,
433
+ labels: nil)
434
+ base_branch ||= settings.main_branch
435
+ remote ||= "origin"
436
+ if !title || !body
437
+ message = last_commit_message.split(/(?:\r?\n)+/, 2)
438
+ title ||= message.first
439
+ body ||= message[1] || ""
440
+ end
441
+ head_branch = current_branch
442
+ @utils.exec(["git", "push", "-f", remote, head_branch], e: true)
443
+ body = ::JSON.dump(title: title,
444
+ head: head_branch,
445
+ base: base_branch,
446
+ body: body,
447
+ maintainer_can_modify: true)
448
+ response = @utils.capture(["gh", "api", "repos/#{settings.repo_path}/pulls", "--input", "-",
449
+ "-H", "Accept: application/vnd.github.v3+json"],
450
+ in: [:string, body], e: true)
451
+ PullRequest.new(self, ::JSON.parse(response)).update(labels: labels)
452
+ end
453
+
454
+ ##
455
+ # Check out to a separate directory
456
+ #
457
+ # @param branch [String] The branch to check out. Defaults to "main".
458
+ # @param remote [String] The remote to pull from. Defaults to "origin".
459
+ # @param dir [String] The diretory to checkout to. If not provided,
460
+ # creates a temporary directory and removes it at process termination.
461
+ # @param gh_token [String] A GitHub token to use for authenticating to
462
+ # GitHub when the remote has an https URL.
463
+ #
464
+ # @return [String] The path to the directory.
465
+ #
466
+ def checkout_separate_dir(branch: nil, remote: nil, dir: nil, gh_token: nil, create: false)
467
+ branch ||= "main"
468
+ remote ||= "origin"
469
+ dir = prepare_directory(dir)
470
+ remote_url = git_remote_url(remote)
471
+ ::Dir.chdir(dir) do
472
+ @utils.exec(["git", "init"], e: true)
473
+ git_set_user_info
474
+ configure_remote_with_token(remote_url, gh_token)
475
+ @utils.exec(["git", "remote", "add", remote, remote_url], e: true)
476
+ result = @utils.exec(["git", "fetch", "--no-tags", "--depth=1", "--no-recurse-submodules", remote, branch])
477
+ if result.success?
478
+ @utils.exec(["git", "branch", branch, "#{remote}/#{branch}"], e: true)
479
+ @utils.exec(["git", "switch", branch], e: true)
480
+ elsif create
481
+ @utils.exec(["git", "switch", "-c", branch], e: true)
482
+ else
483
+ return nil
484
+ end
485
+ end
486
+ dir
487
+ end
488
+
489
+ private
490
+
491
+ def prepare_directory(dir)
492
+ if dir
493
+ ::FileUtils.remove_entry(dir, true)
494
+ ::FileUtils.mkdir_p(dir)
495
+ else
496
+ dir = ::Dir.mktmpdir
497
+ at_exit { ::FileUtils.remove_entry(dir, true) }
498
+ end
499
+ dir
500
+ end
501
+
502
+ def configure_remote_with_token(remote_url, gh_token)
503
+ if remote_url.start_with?("https://github.com/") && gh_token
504
+ encoded_token = ::Base64.strict_encode64("x-access-token:#{gh_token}")
505
+ log_cmd = '["git", "config", "--local", "http.https://github.com/.extraheader", "****"]'
506
+ @utils.exec(["git", "config", "--local", "http.https://github.com/.extraheader",
507
+ "Authorization: Basic #{encoded_token}"],
508
+ log_cmd: log_cmd, e: true)
509
+ end
510
+ end
511
+
512
+ def build_components
513
+ @components = {}
514
+ @utils.accumulate_errors("Errors while validating components") do
515
+ settings.all_component_names.each do |name|
516
+ releasable = Component.build(settings, name, @utils)
517
+ releasable.validate
518
+ @components[releasable.name] = releasable
519
+ end
520
+ end
521
+ @coordination_groups = []
522
+ settings.coordination_groups.each do |name_group|
523
+ component_group = name_group.map { |name| @components[name] }
524
+ component_group.each { |component| component.coordination_group = component_group }
525
+ @coordination_groups << component_group
526
+ end
527
+ @components.each_value do |component|
528
+ next if component.coordination_group
529
+ @coordination_groups << (component.coordination_group = [component])
530
+ end
531
+ self
532
+ end
533
+
534
+ def ensure_gh_binary
535
+ result = @utils.exec(["gh", "--version"], out: :capture)
536
+ match = /^gh version (\d+)\.(\d+)\.(\d+)/.match(result.captured_out.to_s)
537
+ if !result.success? || !match
538
+ @utils.error("gh not installed.",
539
+ "See https://cli.github.com/manual/installation for install instructions.")
540
+ end
541
+ version_val = (match[1].to_i * 1_000_000) + (match[2].to_i * 1000) + match[3].to_i
542
+ version_str = "#{match[1]}.#{match[2]}.#{match[3]}"
543
+ if version_val < 10_000
544
+ @utils.error("gh version 0.10 or later required but #{version_str} found.",
545
+ "See https://cli.github.com/manual/installation for install instructions.")
546
+ end
547
+ @utils.log("gh version #{version_str} found")
548
+ self
549
+ end
550
+
551
+ def ensure_git_binary
552
+ result = @utils.exec(["git", "--version"], out: :capture)
553
+ match = /^git version (\d+)\.(\d+)\.(\d+)/.match(result.captured_out.to_s)
554
+ if !result.success? || !match
555
+ @utils.error("git not installed.",
556
+ "See https://git-scm.com/downloads for install instructions.")
557
+ end
558
+ version_val = (match[1].to_i * 1_000_000) + (match[2].to_i * 1000) + match[3].to_i
559
+ version_str = "#{match[1]}.#{match[2]}.#{match[3]}"
560
+ if version_val < 2_022_000
561
+ @utils.error("git version 2.22 or later required but #{version_str} found.",
562
+ "See https://git-scm.com/downloads for install instructions.")
563
+ end
564
+ @utils.log("git version #{version_str} found")
565
+ self
566
+ end
567
+
568
+ def wait_github_checks_internal(ref, deadline)
569
+ interval = 10
570
+ loop do
571
+ @utils.log("Polling GitHub checks ...")
572
+ errors = github_check_errors(ref)
573
+ if errors.empty?
574
+ @utils.log("GitHub checks all passed.")
575
+ return []
576
+ end
577
+ errors.each { |msg| @utils.log(msg) }
578
+ if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) > deadline
579
+ results = ["GitHub checks still failing after #{required_checks_timeout} secs."]
580
+ return results + errors
581
+ end
582
+ @utils.log("Sleeping for #{interval} secs ...")
583
+ sleep(interval)
584
+ interval += 10 unless interval >= 60
585
+ end
586
+ end
587
+
588
+ def github_check_errors(ref)
589
+ result = @utils.exec(["gh", "api", "repos/#{settings.repo_path}/commits/#{ref}/check-runs",
590
+ "-H", "Accept: application/vnd.github.antiope-preview+json"],
591
+ out: :capture)
592
+ return ["Failed to obtain GitHub check results for #{ref}"] unless result.success?
593
+ checks = ::JSON.parse(result.captured_out)["check_runs"]
594
+ results = []
595
+ results << "No GitHub checks found for #{ref}" if checks.empty?
596
+ checks.each do |check|
597
+ name = check["name"]
598
+ next if @settings.release_jobs_regexp.match(name)
599
+ next unless @settings.required_checks_regexp.match(name)
600
+ if check["status"] != "completed"
601
+ results << "GitHub check #{name.inspect} is not complete"
602
+ elsif check["conclusion"] != "success"
603
+ results << "GitHub check #{name.inspect} was not successful"
604
+ end
605
+ end
606
+ results
607
+ end
608
+
609
+ ##
610
+ # Attempt to get the component and version from the pull request branch
611
+ # name
612
+ #
613
+ def single_released_component_and_version(pull_request)
614
+ component_name =
615
+ if @settings.all_component_names.size == 1
616
+ @settings.default_component_name
617
+ else
618
+ component_name_from_release_branch(pull_request.head_ref)
619
+ end
620
+ return nil unless component_name
621
+ component = component_named(component_name)
622
+ unless component
623
+ @utils.warning("Release branch references nonexistent component #{component_name.inspect}")
624
+ return nil
625
+ end
626
+ version = component.current_changelog_version(at: pull_request.merge_commit_sha)
627
+ @utils.log("Found single component to release: #{component_name} #{version}.")
628
+ { component_name => version }
629
+ end
630
+
631
+ ##
632
+ # Get components and versions from the pull request content
633
+ #
634
+ def multiple_released_components_and_versions(pull_request)
635
+ merge_sha = pull_request.merge_commit_sha
636
+ output = @utils.capture(["git", "diff", "--name-only", "#{merge_sha}^..#{merge_sha}"], e: true)
637
+ files = output.split("\n")
638
+ components = all_components.find_all do |component|
639
+ dir = component.directory
640
+ files.any? { |file| file.start_with?(dir) }
641
+ end
642
+ components.each_with_object({}) do |component, result|
643
+ result[component.name] = version = component.current_changelog_version(at: merge_sha)
644
+ @utils.log("Releasing gem due to file changes: #{component.name} #{version}.")
645
+ end
646
+ end
647
+
648
+ ##
649
+ # Recover a component name from a release branch name
650
+ #
651
+ # @param name [String] The branch name
652
+ # @return [String,nil] The component name, or nil if not a release branch
653
+ # or the release branch covers multiple components
654
+ #
655
+ def component_name_from_release_branch(name)
656
+ match = %r{^#{settings.release_branch_prefix}/component/([^/]+)/}.match(name)
657
+ match ? match[1] : nil
658
+ end
659
+ end
660
+ end
661
+ end