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.
- checksums.yaml +7 -0
- data/.yardopts +11 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.md +21 -0
- data/README.md +87 -0
- data/docs/guide.md +7 -0
- data/lib/toys/release/version.rb +11 -0
- data/lib/toys-release.rb +23 -0
- data/toys/.data/templates/gh-pages-404.html.erb +25 -0
- data/toys/.data/templates/gh-pages-empty.html.erb +11 -0
- data/toys/.data/templates/gh-pages-gitignore.erb +1 -0
- data/toys/.data/templates/gh-pages-index.html.erb +15 -0
- data/toys/.data/templates/release-hook-on-closed.yml.erb +34 -0
- data/toys/.data/templates/release-hook-on-open.yml.erb +30 -0
- data/toys/.data/templates/release-hook-on-push.yml.erb +32 -0
- data/toys/.data/templates/release-perform.yml.erb +46 -0
- data/toys/.data/templates/release-request.yml.erb +37 -0
- data/toys/.data/templates/release-retry.yml.erb +42 -0
- data/toys/.lib/toys/release/artifact_dir.rb +70 -0
- data/toys/.lib/toys/release/change_set.rb +259 -0
- data/toys/.lib/toys/release/changelog_file.rb +136 -0
- data/toys/.lib/toys/release/component.rb +388 -0
- data/toys/.lib/toys/release/environment_utils.rb +246 -0
- data/toys/.lib/toys/release/performer.rb +346 -0
- data/toys/.lib/toys/release/pull_request.rb +154 -0
- data/toys/.lib/toys/release/repo_settings.rb +855 -0
- data/toys/.lib/toys/release/repository.rb +661 -0
- data/toys/.lib/toys/release/request_logic.rb +217 -0
- data/toys/.lib/toys/release/request_spec.rb +188 -0
- data/toys/.lib/toys/release/semver.rb +112 -0
- data/toys/.lib/toys/release/steps.rb +580 -0
- data/toys/.lib/toys/release/version_rb_file.rb +91 -0
- data/toys/.toys.rb +5 -0
- data/toys/_onclosed.rb +113 -0
- data/toys/_onopen.rb +158 -0
- data/toys/_onpush.rb +57 -0
- data/toys/create-labels.rb +115 -0
- data/toys/gen-gh-pages.rb +146 -0
- data/toys/gen-settings.rb +46 -0
- data/toys/gen-workflows.rb +70 -0
- data/toys/perform.rb +152 -0
- data/toys/request.rb +162 -0
- data/toys/retry.rb +133 -0
- 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
|