toys-release 0.1.1 → 0.2.2

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,548 @@
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] Path in the destination
240
+ #
241
+ def copy_from_input(source_step, source_path: nil, dest: :component, dest_path: nil)
242
+ source_dir = output_dir(source_step)
243
+ source_path ||= "."
244
+ dest_path ||= source_path
245
+ dest_dir =
246
+ case dest
247
+ when :component
248
+ component.directory(from: :absolute)
249
+ when :repo_root
250
+ @pipeline.utils.repo_root_directory
251
+ when :output
252
+ output_dir
253
+ when :temp
254
+ temp_dir
255
+ else
256
+ abort_pipeline("Unrecognized destination for copy_from_input: #{source.inspect}")
257
+ end
258
+ source = ::File.expand_path(source_path, source_dir)
259
+ dest = ::File.expand_path(dest_path, dest_dir)
260
+ utils.log("Copying #{source_path.inspect} from step #{source_step.inspect}")
261
+ @pipeline.copy_tree(self, source, dest, source_path)
262
+ end
263
+
264
+ ##
265
+ # Copy the given item to the output directory
266
+ #
267
+ # @param source_path [String] Path to the file or directory to copy
268
+ # @param source [:component,:repo_root,:temp] Symbolic source
269
+ #
270
+ def copy_to_output(source: :component, source_path: nil, dest_path: nil)
271
+ source_path ||= "."
272
+ dest_path ||= source_path
273
+ source_dir =
274
+ case source
275
+ when :component
276
+ component.directory(from: :absolute)
277
+ when :repo_root
278
+ @pipeline.utils.repo_root_directory
279
+ when :temp
280
+ temp_dir
281
+ else
282
+ abort_pipeline("Unrecognized source for copy_to_output: #{source.inspect}")
283
+ end
284
+ source = ::File.expand_path(source_path, source_dir)
285
+ dest = ::File.expand_path(dest_path, output_dir)
286
+ utils.log("Copying #{source_path.inspect} to output")
287
+ @pipeline.copy_tree(self, source, dest, source_path)
288
+ end
289
+
290
+ # ---- called internally from the pipeline ----
291
+
292
+ # @private
293
+ def mark_will_run!
294
+ @will_run = true
295
+ end
296
+
297
+ # @private
298
+ def primary?
299
+ @step_impl.respond_to?(:primary?) && @step_impl.primary?(self)
300
+ end
301
+
302
+ # @private
303
+ def dependencies
304
+ if @step_impl.respond_to?(:dependencies)
305
+ Array(@step_impl.dependencies(self))
306
+ else
307
+ []
308
+ end
309
+ end
310
+
311
+ # @private
312
+ def run!
313
+ @step_impl.run(self) if @step_impl.respond_to?(:run)
314
+ end
315
+ end
316
+
317
+ ##
318
+ # Construct the pipeline context
319
+ #
320
+ def initialize(repository:, component:, version:, performer_result:, artifact_dir:, dry_run:, git_remote:)
321
+ @repository = repository
322
+ @component = component
323
+ @release_version = version
324
+ @performer_result = performer_result
325
+ @artifact_dir = artifact_dir
326
+ @dry_run = dry_run
327
+ @git_remote = git_remote || "origin"
328
+ @utils = repository.utils
329
+ @steps = []
330
+ @steps_locked = false
331
+ end
332
+
333
+ attr_reader :repository
334
+ attr_reader :component
335
+ attr_reader :release_version
336
+ attr_reader :performer_result
337
+ attr_reader :artifact_dir
338
+ attr_reader :dry_run
339
+ attr_reader :git_remote
340
+ attr_reader :utils
341
+
342
+ ##
343
+ # Add a step
344
+ #
345
+ # @param step_settings [Toys::Release::StepSettings] step settings
346
+ # @return [Toys::Release::Pipeline::StepContext]
347
+ #
348
+ def add_step(step_settings)
349
+ raise "Steps locked" if @steps_locked
350
+ step = StepContext.new(self, step_settings)
351
+ @steps << step
352
+ step
353
+ end
354
+
355
+ ##
356
+ # Resolve which steps should run
357
+ #
358
+ def resolve_run
359
+ @utils.log("Resolving which steps to run...")
360
+ @steps_locked = true
361
+ @steps.each_with_index do |step, index|
362
+ if step.requested?
363
+ @utils.log("Step #{step.name} is explicitly requested in config")
364
+ mark_step_index(index)
365
+ elsif step.primary?
366
+ @utils.log("Step #{step.name} declares itself as a primary step")
367
+ mark_step_index(index)
368
+ end
369
+ end
370
+ end
371
+
372
+ ##
373
+ # Run the runnable steps in the pipeline
374
+ #
375
+ def run
376
+ @steps.each do |step|
377
+ unless step.will_run?
378
+ @utils.log("Skipping step #{step.name}")
379
+ next
380
+ end
381
+ begin
382
+ clean_repo(step)
383
+ pull_inputs(step)
384
+ @utils.log("Running step #{step.name}")
385
+ step.run!
386
+ @utils.log("Completed step #{step.name}")
387
+ push_outputs(step)
388
+ rescue StepExit => e
389
+ @utils.log("Exited step #{step.name}: #{e.message}")
390
+ # Continue
391
+ rescue PipelineExit => e
392
+ @utils.log("Aborted pipeline: #{e.message}")
393
+ return nil
394
+ end
395
+ end
396
+ end
397
+
398
+ ##
399
+ # @private
400
+ #
401
+ def copy_tree(step, src, dest, src_name)
402
+ if ::File.directory?(src)
403
+ if ::File.exist?(dest) && !::File.directory?(dest)
404
+ step.abort_pipeline("Unable to copy #{src_name} because a non-directory exists at the destination")
405
+ end
406
+ ::FileUtils.mkdir_p(dest)
407
+ ::Dir.children(src).each do |child|
408
+ copy_tree(step, ::File.join(src, child), ::File.join(dest, child), ::File.join(src_name, child))
409
+ end
410
+ elsif ::File.exist?(src)
411
+ if ::File.exist?(dest)
412
+ step.abort_pipeline("Unable to copy #{src_name} because something already exists at the destination")
413
+ end
414
+ ::FileUtils.copy_entry(src, dest)
415
+ else
416
+ step.abort_pipeline("Unable to copy #{src_name} because it does not exist")
417
+ end
418
+ end
419
+
420
+ private
421
+
422
+ ##
423
+ # @private
424
+ # Recursive routine to mark steps as runnable
425
+ #
426
+ def mark_step_index(index)
427
+ step = @steps[index]
428
+ return if step.will_run?
429
+ step.mark_will_run!
430
+ step.input_settings.each do |input_settings|
431
+ dep_index = @steps[...index].find_index { |item| item.name == input_settings.step_name }
432
+ unless dep_index
433
+ @utils.error("Input dependency #{input_settings.name} not found before step #{step.name}")
434
+ return nil
435
+ end
436
+ @utils.log("Step #{@steps[dep_index].name} requested as a dependency of #{step.name}")
437
+ mark_step_index(dep_index)
438
+ end
439
+ step.dependencies.each do |dep_name|
440
+ dep_index = @steps[...index].find_index { |item| item.name == dep_name }
441
+ unless dep_index
442
+ @utils.error("Dependency #{dep_name} not found before step #{step.name}")
443
+ return nil
444
+ end
445
+ @utils.log("Step #{dep_name} requested as a dependency of #{step.name}")
446
+ mark_step_index(dep_index)
447
+ end
448
+ end
449
+
450
+ ##
451
+ # @private
452
+ # Entry point to clean the repo
453
+ #
454
+ def clean_repo(step)
455
+ if step.option("clean") == false
456
+ @utils.log("Pre-cleaning disabled by the step #{step.name}")
457
+ return
458
+ end
459
+ @utils.log("Pre-cleaning the repo for step #{step.name}")
460
+ count = clean_tree(nil)
461
+ @utils.log("Cleaned #{count} items") if count.positive?
462
+ end
463
+
464
+ ##
465
+ # @private
466
+ # Recursive repo cleaner
467
+ #
468
+ def clean_tree(subdir)
469
+ count = 0
470
+ ::Dir.children(subdir || ".").each do |child|
471
+ next if child == ".git"
472
+ child = ::File.join(subdir, child) if subdir
473
+ if ::File.directory?(child)
474
+ clean_tree(child)
475
+ elsif !git_files.include?(child)
476
+ count += 1
477
+ @utils.log("Cleaning: #{child}")
478
+ ::FileUtils.rm_rf(child)
479
+ end
480
+ end
481
+ count
482
+ end
483
+
484
+ ##
485
+ # @private
486
+ # Return all files known by git
487
+ #
488
+ def git_files
489
+ @git_files ||= @utils.capture(["git", "ls-files"], e: true).strip.split("\n")
490
+ end
491
+
492
+ ##
493
+ # @private
494
+ # Pull data from all inputs for the given step
495
+ #
496
+ def pull_inputs(step)
497
+ step.input_settings.each do |input|
498
+ next if input.dest == "none"
499
+ source_path = input.source_path || "."
500
+ dest_path = input.dest_path || source_path
501
+ source = ::File.expand_path(source_path, @artifact_dir.output(input.step_name))
502
+ dest_dir =
503
+ case input.dest
504
+ when "component"
505
+ step.component.directory(from: :absolute)
506
+ when "repo_root"
507
+ @utils.repo_root_directory
508
+ when "output"
509
+ step.output_dir
510
+ when "temp"
511
+ step.temp_dir
512
+ else
513
+ step.abort_pipeline("Unrecognized destination for input: #{input.dest.inspect}")
514
+ end
515
+ dest = ::File.expand_path(dest_path, dest_dir)
516
+ @utils.log("Copying #{source_path.inspect} from step #{input.step_name.inspect}")
517
+ copy_tree(step, source, dest, source_path)
518
+ end
519
+ end
520
+
521
+ ##
522
+ # @private
523
+ # Push data to output from the given step
524
+ #
525
+ def push_outputs(step)
526
+ step.output_settings.each do |output|
527
+ source_path = output.source_path || "."
528
+ dest_path = output.dest_path || source_path
529
+ source_dir =
530
+ case output.source
531
+ when "component"
532
+ step.component.directory(from: :absolute)
533
+ when "repo_root"
534
+ @utils.repo_root_directory
535
+ when "temp"
536
+ step.temp_dir
537
+ else
538
+ step.abort_pipeline("Unrecognized source for output: #{output.source.inspect}")
539
+ end
540
+ source = ::File.expand_path(source_path, source_dir)
541
+ dest = ::File.expand_path(dest_path, step.output_dir)
542
+ @utils.log("Copying #{source_path.inspect} to output")
543
+ copy_tree(step, source, dest, source_path)
544
+ end
545
+ end
546
+ end
547
+ end
548
+ 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
  ##