toys-release 0.1.1 → 0.3.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.
@@ -0,0 +1,577 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Toys
6
+ module Release
7
+ ##
8
+ # The pipeline context
9
+ #
10
+ class Pipeline
11
+ ##
12
+ # Internal exception signaling that the step should end immediately but
13
+ # the pipeline should continue.
14
+ # @private
15
+ #
16
+ class StepExit < ::StandardError
17
+ end
18
+
19
+ ##
20
+ # Internal exception signaling that the step should end immediately and
21
+ # the pipeline should be aborted.
22
+ # @private
23
+ #
24
+ class PipelineExit < ::StandardError
25
+ end
26
+
27
+ ##
28
+ # Context provided to a step implementation
29
+ #
30
+ class StepContext
31
+ # @private
32
+ def initialize(pipeline, step_settings)
33
+ @pipeline = pipeline
34
+ @step_settings = step_settings
35
+ type_name = step_settings.type
36
+ type_name = type_name.upcase unless type_name =~ /^[A-Z]/
37
+ @step_impl = begin
38
+ ::Toys::Release::Steps.const_get(type_name)
39
+ rescue ::NameError
40
+ pipeline.repository.utils.error("Unknown step type: #{type_name}")
41
+ end
42
+ @step_impl = @step_impl.new if @step_impl.is_a?(::Class)
43
+ @will_run = false
44
+ end
45
+
46
+ ##
47
+ # @return [boolean] Whether this step has been marked as will_run
48
+ #
49
+ def will_run?
50
+ @will_run
51
+ end
52
+
53
+ ##
54
+ # @return [boolean] Whether this step is explicitly requested in config
55
+ #
56
+ def requested?
57
+ @step_settings.requested?
58
+ end
59
+
60
+ ##
61
+ # @return [String] The step name
62
+ #
63
+ def name
64
+ @step_settings.name
65
+ end
66
+
67
+ ##
68
+ # @return [Array<Toys::Release::InputSettings>] The input settings
69
+ #
70
+ def input_settings
71
+ @step_settings.inputs
72
+ end
73
+
74
+ ##
75
+ # @return [Array<Toys::Release::OutputSettings>] The output settings
76
+ #
77
+ def output_settings
78
+ @step_settings.outputs
79
+ end
80
+
81
+ ##
82
+ # @return [Toys::Release::EnvironmentUtils] Environment utils
83
+ #
84
+ def utils
85
+ @pipeline.utils
86
+ end
87
+
88
+ ##
89
+ # @return [Toys::Release::Repository] The repository
90
+ #
91
+ def repository
92
+ @pipeline.repository
93
+ end
94
+
95
+ ##
96
+ # @return [Toys::Release::Component] Component being released
97
+ #
98
+ def component
99
+ @pipeline.component
100
+ end
101
+
102
+ ##
103
+ # @return [::Gem::Version] Version being released
104
+ #
105
+ def release_version
106
+ @pipeline.release_version
107
+ end
108
+
109
+ ##
110
+ # @return [String] The name of the git remote
111
+ #
112
+ def git_remote
113
+ @pipeline.git_remote
114
+ end
115
+
116
+ ##
117
+ # @return [boolean] Whether this is running in dry run mode
118
+ #
119
+ def dry_run?
120
+ @pipeline.dry_run
121
+ end
122
+
123
+ ##
124
+ # @return [String] Short description of the release, including the
125
+ # component name and version
126
+ #
127
+ def release_description
128
+ "#{component.name} #{release_version}"
129
+ end
130
+
131
+ ##
132
+ # @return [String] Name of the gem package for this release
133
+ #
134
+ def gem_package_name
135
+ "#{component.name}-#{release_version}.gem"
136
+ end
137
+
138
+ ##
139
+ # @return [String] Name of the git tag
140
+ #
141
+ def tag_name
142
+ "#{component.name}/v#{release_version}"
143
+ end
144
+
145
+ ##
146
+ # Log a message
147
+ #
148
+ # @param message [String] Message to log
149
+ #
150
+ def log(message)
151
+ @pipeline.utils.log(message)
152
+ end
153
+
154
+ ##
155
+ # Log a warning
156
+ #
157
+ # @param message [String] Message to log
158
+ #
159
+ def warning(message)
160
+ @pipeline.utils.warning(message)
161
+ end
162
+
163
+ ##
164
+ # Add a message to the successes list
165
+ #
166
+ # @param message [String] Success to report
167
+ #
168
+ def add_success(message)
169
+ @pipeline.performer_result.successes << message
170
+ end
171
+
172
+ ##
173
+ # Exit the step immediately, but does not abort the pipeline.
174
+ # If an error message is given, it is added to the error stream.
175
+ #
176
+ # @param error_message [String] Optional error message
177
+ #
178
+ def exit_step(error_message = nil)
179
+ utils.error(error_message) if error_message
180
+ raise StepExit, error_message
181
+ end
182
+
183
+ ##
184
+ # Exit the step immediately, and abort the pipeline.
185
+ # The error message is added to the error stream.
186
+ #
187
+ # @param error_message [String] Required error message
188
+ #
189
+ def abort_pipeline(error_message)
190
+ utils.error(error_message)
191
+ raise PipelineExit, error_message
192
+ end
193
+
194
+ ##
195
+ # Get the option with the given key.
196
+ #
197
+ # @param key [String] Option name to fetch
198
+ # @param required [boolean] Whether to exit with an error if the option
199
+ # is not set. Defaults to false, which instead returns the default.
200
+ # @param default [Object] Default value to return if the option is not
201
+ # set and required is set to false.
202
+ #
203
+ # @return [Object] The option value
204
+ #
205
+ def option(key, required: false, default: nil)
206
+ return @step_settings.options[key] if @step_settings.options.key?(key)
207
+ return default unless required
208
+ abort_pipeline("Missing required option: #{key.inspect} for step #{name.inspect}")
209
+ end
210
+
211
+ ##
212
+ # Get the path to an output directory.
213
+ # If the step_name argument is provided, its output directory is
214
+ # returned. Otherwise, the current step's output directory is returned.
215
+ #
216
+ # @param step_name [String,nil] Optional name of the step whose
217
+ # directory should be returned.
218
+ # @return [String]
219
+ #
220
+ def output_dir(step_name = nil)
221
+ @pipeline.artifact_dir.output(step_name || name)
222
+ end
223
+
224
+ ##
225
+ # Get the path to a private temporary directory for use by this step.
226
+ #
227
+ # @return [String]
228
+ #
229
+ def temp_dir
230
+ @pipeline.artifact_dir.temp(name)
231
+ end
232
+
233
+ ##
234
+ # Copy the given item from an input directory
235
+ #
236
+ # @param source_step [String] Name of the source step
237
+ # @param source_path [String] Path to the file or directory to copy
238
+ # @param dest [:component,:repo_root,:temp,:output] Symbolic destination
239
+ # @param dest_path [String,nil] Path in the destination, if different
240
+ # from the source
241
+ # @param collisions [:error,:replace,:keep] What to do if a collision
242
+ # occurs
243
+ #
244
+ def copy_from_input(source_step, source_path: nil, dest: :component, dest_path: nil, collisions: nil)
245
+ collisions ||= "error"
246
+ source_dir = output_dir(source_step)
247
+ source_path ||= "."
248
+ dest_path ||= source_path
249
+ dest_dir =
250
+ case dest
251
+ when :component
252
+ component.directory(from: :absolute)
253
+ when :repo_root
254
+ @pipeline.utils.repo_root_directory
255
+ when :output
256
+ output_dir
257
+ when :temp
258
+ temp_dir
259
+ else
260
+ abort_pipeline("Unrecognized destination for copy_from_input: #{source.inspect}")
261
+ end
262
+ source = ::File.expand_path(source_path, source_dir)
263
+ dest = ::File.expand_path(dest_path, dest_dir)
264
+ utils.log("Copying #{source_path.inspect} from step #{source_step.inspect}")
265
+ @pipeline.copy_tree(self, source, dest, source_path, collisions.to_s)
266
+ end
267
+
268
+ ##
269
+ # Copy the given item to the output directory
270
+ #
271
+ # @param source [:component,:repo_root,:temp] Symbolic source
272
+ # @param source_path [String] Path to the file or directory to copy
273
+ # @param dest_path [String,nil] Path in the destination, if different
274
+ # from the source
275
+ # @param collisions [:error,:replace,:keep] What to do if a collision
276
+ # occurs
277
+ #
278
+ def copy_to_output(source: :component, source_path: nil, dest_path: nil, collisions: nil)
279
+ collisions ||= "error"
280
+ source_path ||= "."
281
+ dest_path ||= source_path
282
+ source_dir =
283
+ case source
284
+ when :component
285
+ component.directory(from: :absolute)
286
+ when :repo_root
287
+ @pipeline.utils.repo_root_directory
288
+ when :temp
289
+ temp_dir
290
+ else
291
+ abort_pipeline("Unrecognized source for copy_to_output: #{source.inspect}")
292
+ end
293
+ source = ::File.expand_path(source_path, source_dir)
294
+ dest = ::File.expand_path(dest_path, output_dir)
295
+ utils.log("Copying #{source_path.inspect} to output")
296
+ @pipeline.copy_tree(self, source, dest, source_path, collisions.to_s)
297
+ end
298
+
299
+ # ---- called internally from the pipeline ----
300
+
301
+ # @private
302
+ def mark_will_run!
303
+ @will_run = true
304
+ end
305
+
306
+ # @private
307
+ def primary?
308
+ @step_impl.respond_to?(:primary?) && @step_impl.primary?(self)
309
+ end
310
+
311
+ # @private
312
+ def dependencies
313
+ if @step_impl.respond_to?(:dependencies)
314
+ Array(@step_impl.dependencies(self))
315
+ else
316
+ []
317
+ end
318
+ end
319
+
320
+ # @private
321
+ def run!
322
+ @step_impl.run(self) if @step_impl.respond_to?(:run)
323
+ end
324
+ end
325
+
326
+ ##
327
+ # Construct the pipeline context
328
+ #
329
+ def initialize(repository:, component:, version:, performer_result:, artifact_dir:, dry_run:, git_remote:)
330
+ @repository = repository
331
+ @component = component
332
+ @release_version = version
333
+ @performer_result = performer_result
334
+ @artifact_dir = artifact_dir
335
+ @dry_run = dry_run
336
+ @git_remote = git_remote || "origin"
337
+ @utils = repository.utils
338
+ @steps = []
339
+ @steps_locked = false
340
+ end
341
+
342
+ attr_reader :repository
343
+ attr_reader :component
344
+ attr_reader :release_version
345
+ attr_reader :performer_result
346
+ attr_reader :artifact_dir
347
+ attr_reader :dry_run
348
+ attr_reader :git_remote
349
+ attr_reader :utils
350
+
351
+ ##
352
+ # Add a step
353
+ #
354
+ # @param step_settings [Toys::Release::StepSettings] step settings
355
+ # @return [Toys::Release::Pipeline::StepContext]
356
+ #
357
+ def add_step(step_settings)
358
+ raise "Steps locked" if @steps_locked
359
+ step = StepContext.new(self, step_settings)
360
+ @steps << step
361
+ step
362
+ end
363
+
364
+ ##
365
+ # Resolve which steps should run
366
+ #
367
+ def resolve_run
368
+ @utils.log("Resolving which steps to run...")
369
+ @steps_locked = true
370
+ @steps.each_with_index do |step, index|
371
+ if step.requested?
372
+ @utils.log("Step #{step.name} is explicitly requested in config")
373
+ mark_step_index(index)
374
+ elsif step.primary?
375
+ @utils.log("Step #{step.name} declares itself as a primary step")
376
+ mark_step_index(index)
377
+ end
378
+ end
379
+ self
380
+ end
381
+
382
+ ##
383
+ # Run the runnable steps in the pipeline
384
+ #
385
+ def run
386
+ @steps.each do |step|
387
+ unless step.will_run?
388
+ @utils.log("Skipping step #{step.name}")
389
+ next
390
+ end
391
+ begin
392
+ clean_repo(step)
393
+ pull_inputs(step)
394
+ @utils.log("Running step #{step.name}")
395
+ step.run!
396
+ @utils.log("Completed step #{step.name}")
397
+ push_outputs(step)
398
+ rescue StepExit => e
399
+ @utils.log("Exited step #{step.name}: #{e.message}")
400
+ # Continue
401
+ rescue PipelineExit => e
402
+ @utils.log("Aborted pipeline: #{e.message}")
403
+ return nil
404
+ end
405
+ end
406
+ self
407
+ end
408
+
409
+ ##
410
+ # @private
411
+ #
412
+ def copy_tree(step, src, dest, src_name, collisions)
413
+ if ::File.directory?(src)
414
+ if ::File.exist?(dest) && !::File.directory?(dest)
415
+ return if handle_copy_collision(step, collisions, dest, src_name) == :keep
416
+ end
417
+ ::FileUtils.mkdir_p(dest)
418
+ ::Dir.children(src).each do |child|
419
+ copy_tree(step, ::File.join(src, child), ::File.join(dest, child),
420
+ ::File.join(src_name, child), collisions)
421
+ end
422
+ elsif ::File.exist?(src)
423
+ if ::File.exist?(dest)
424
+ return if handle_copy_collision(step, collisions, dest, src_name) == :keep
425
+ end
426
+ ::FileUtils.copy_entry(src, dest)
427
+ else
428
+ step.abort_pipeline("Unable to copy #{src_name} because it does not exist")
429
+ end
430
+ end
431
+
432
+ private
433
+
434
+ ##
435
+ # @private
436
+ # Recursive routine to mark steps as runnable
437
+ #
438
+ def mark_step_index(index)
439
+ step = @steps[index]
440
+ return if step.will_run?
441
+ step.mark_will_run!
442
+ step.input_settings.each do |input_settings|
443
+ dep_index = @steps[...index].find_index { |item| item.name == input_settings.step_name }
444
+ unless dep_index
445
+ @utils.error("Input dependency #{input_settings.name} not found before step #{step.name}")
446
+ return nil
447
+ end
448
+ @utils.log("Step #{@steps[dep_index].name} requested as a dependency of #{step.name}")
449
+ mark_step_index(dep_index)
450
+ end
451
+ step.dependencies.each do |dep_name|
452
+ dep_index = @steps[...index].find_index { |item| item.name == dep_name }
453
+ unless dep_index
454
+ @utils.error("Dependency #{dep_name} not found before step #{step.name}")
455
+ return nil
456
+ end
457
+ @utils.log("Step #{dep_name} requested as a dependency of #{step.name}")
458
+ mark_step_index(dep_index)
459
+ end
460
+ end
461
+
462
+ ##
463
+ # @private
464
+ # Entry point to clean the repo
465
+ #
466
+ def clean_repo(step)
467
+ if step.option("clean") == false
468
+ @utils.log("Pre-cleaning disabled by the step #{step.name}")
469
+ return
470
+ end
471
+ @utils.log("Pre-cleaning the repo for step #{step.name}")
472
+ count = clean_tree(nil)
473
+ @utils.log("Cleaned #{count} items") if count.positive?
474
+ end
475
+
476
+ ##
477
+ # @private
478
+ # Recursive repo cleaner
479
+ #
480
+ def clean_tree(subdir)
481
+ count = 0
482
+ ::Dir.children(subdir || ".").each do |child|
483
+ next if child == ".git"
484
+ child = ::File.join(subdir, child) if subdir
485
+ if ::File.directory?(child)
486
+ clean_tree(child)
487
+ elsif !git_files.include?(child)
488
+ count += 1
489
+ @utils.log("Cleaning: #{child}")
490
+ ::FileUtils.rm_rf(child)
491
+ end
492
+ end
493
+ count
494
+ end
495
+
496
+ ##
497
+ # @private
498
+ # Return all files known by git
499
+ #
500
+ def git_files
501
+ @git_files ||= @utils.capture(["git", "ls-files"], e: true).strip.split("\n")
502
+ end
503
+
504
+ ##
505
+ # @private
506
+ # Pull data from all inputs for the given step
507
+ #
508
+ def pull_inputs(step)
509
+ step.input_settings.each do |input|
510
+ next if input.dest == "none"
511
+ source_path = input.source_path || "."
512
+ dest_path = input.dest_path || source_path
513
+ source = ::File.expand_path(source_path, @artifact_dir.output(input.step_name))
514
+ dest_dir =
515
+ case input.dest
516
+ when "component"
517
+ step.component.directory(from: :absolute)
518
+ when "repo_root"
519
+ @utils.repo_root_directory
520
+ when "output"
521
+ step.output_dir
522
+ when "temp"
523
+ step.temp_dir
524
+ else
525
+ step.abort_pipeline("Unrecognized destination for input: #{input.dest.inspect}")
526
+ end
527
+ dest = ::File.expand_path(dest_path, dest_dir)
528
+ @utils.log("Copying #{source_path.inspect} from step #{input.step_name.inspect}")
529
+ copy_tree(step, source, dest, source_path, input.collisions)
530
+ end
531
+ end
532
+
533
+ ##
534
+ # @private
535
+ # Push data to output from the given step
536
+ #
537
+ def push_outputs(step)
538
+ step.output_settings.each do |output|
539
+ source_path = output.source_path || "."
540
+ dest_path = output.dest_path || source_path
541
+ source_dir =
542
+ case output.source
543
+ when "component"
544
+ step.component.directory(from: :absolute)
545
+ when "repo_root"
546
+ @utils.repo_root_directory
547
+ when "temp"
548
+ step.temp_dir
549
+ else
550
+ step.abort_pipeline("Unrecognized source for output: #{output.source.inspect}")
551
+ end
552
+ source = ::File.expand_path(source_path, source_dir)
553
+ dest = ::File.expand_path(dest_path, step.output_dir)
554
+ @utils.log("Copying #{source_path.inspect} to output")
555
+ copy_tree(step, source, dest, source_path, output.collisions)
556
+ end
557
+ end
558
+
559
+ ##
560
+ # @private
561
+ # Handle a collision during copy_tree.
562
+ # Returns :keep or :replace
563
+ #
564
+ def handle_copy_collision(step, collisions, dest, src_name)
565
+ case collisions
566
+ when "keep"
567
+ :keep
568
+ when "replace"
569
+ ::FileUtils.remove_entry(dest)
570
+ :replace
571
+ else
572
+ step.abort_pipeline("Unable to copy #{src_name} because it already exists at the destination")
573
+ end
574
+ end
575
+ end
576
+ end
577
+ end
@@ -2,8 +2,6 @@
2
2
 
3
3
  require "json"
4
4
 
5
- require_relative "semver"
6
-
7
5
  module Toys
8
6
  module Release
9
7
  ##