boringbuilder 0.1.0.alpha.1

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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +38 -0
  3. data/CHANGELOG.md +13 -0
  4. data/LICENSE +21 -0
  5. data/README.md +131 -0
  6. data/SECURITY.md +25 -0
  7. data/SOUL.md +40 -0
  8. data/STYLE.md +33 -0
  9. data/docs/artifacts.md +106 -0
  10. data/docs/building.md +175 -0
  11. data/docs/caching.md +53 -0
  12. data/exe/boringbuilder +6 -0
  13. data/lib/boringbuilder/artifact.rb +87 -0
  14. data/lib/boringbuilder/boring_cache.rb +126 -0
  15. data/lib/boringbuilder/build_progress.rb +90 -0
  16. data/lib/boringbuilder/builder.rb +33 -0
  17. data/lib/boringbuilder/cli.rb +234 -0
  18. data/lib/boringbuilder/config_file.rb +40 -0
  19. data/lib/boringbuilder/configuration.rb +165 -0
  20. data/lib/boringbuilder/errors.rb +9 -0
  21. data/lib/boringbuilder/exporter.rb +144 -0
  22. data/lib/boringbuilder/exporters/asset.rb +7 -0
  23. data/lib/boringbuilder/exporters/boring_cache.rb +83 -0
  24. data/lib/boringbuilder/exporters/local.rb +27 -0
  25. data/lib/boringbuilder/exporters/receipt.rb +11 -0
  26. data/lib/boringbuilder/exporters/resolver.rb +57 -0
  27. data/lib/boringbuilder/exporters.rb +7 -0
  28. data/lib/boringbuilder/initializer.rb +180 -0
  29. data/lib/boringbuilder/mise.rb +88 -0
  30. data/lib/boringbuilder/pipeline.rb +156 -0
  31. data/lib/boringbuilder/project.rb +264 -0
  32. data/lib/boringbuilder/project_plan.rb +78 -0
  33. data/lib/boringbuilder/rails_build.rb +6 -0
  34. data/lib/boringbuilder/railtie.rb +9 -0
  35. data/lib/boringbuilder/result.rb +33 -0
  36. data/lib/boringbuilder/ruby_application.rb +142 -0
  37. data/lib/boringbuilder/ruby_build.rb +212 -0
  38. data/lib/boringbuilder/runtime.rb +89 -0
  39. data/lib/boringbuilder/tasks/boringbuilder.rake +40 -0
  40. data/lib/boringbuilder/version.rb +5 -0
  41. data/lib/boringbuilder.rb +45 -0
  42. metadata +108 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module BoringBuilder
6
+ class Artifact
7
+ Entry = Data.define(:origin, :kind, :source, :destination) do
8
+ def to_h
9
+ { origin: origin, kind: kind, source: source.to_s, destination: destination }
10
+ end
11
+ end
12
+
13
+ attr_reader :root, :entries
14
+
15
+ def initialize(root: Dir.pwd)
16
+ @root = Pathname.new(root).expand_path
17
+ @entries = []
18
+ end
19
+
20
+ def directory(source, at: source)
21
+ add(:container, :directory, source, at)
22
+ end
23
+
24
+ def file(source, at: source)
25
+ add(:container, :file, source, at)
26
+ end
27
+
28
+ def host_path(source, at:)
29
+ path = Pathname.new(source).expand_path(root)
30
+ kind = path.file? ? :file : :directory
31
+ raise ConfigurationError, "Host artifact path does not exist: #{path}" unless path.exist?
32
+
33
+ add(:host, kind, path, at)
34
+ end
35
+
36
+ def empty?
37
+ entries.empty?
38
+ end
39
+
40
+ def full_container_root?
41
+ entries.one? && entries.first.origin == :container && entries.first.kind == :directory &&
42
+ entries.first.source == "/" && entries.first.destination == "/"
43
+ end
44
+
45
+ def export_directory(client, container)
46
+ return container.directory("/") if full_container_root?
47
+
48
+ entries.reduce(client.directory) do |directory, entry|
49
+ destination = entry.destination.delete_prefix("/")
50
+ source = source_for(entry, client, container)
51
+
52
+ if entry.kind == :file
53
+ directory.with_file(destination, source)
54
+ else
55
+ directory.with_directory(destination, source)
56
+ end
57
+ end
58
+ end
59
+
60
+ def to_a
61
+ entries.map(&:to_h)
62
+ end
63
+
64
+ private
65
+
66
+ def add(origin, kind, source, destination)
67
+ source = normalize_container_path(source, "source") if origin == :container
68
+ destination = normalize_container_path(destination, "destination")
69
+ entries << Entry.new(origin: origin, kind: kind, source: source, destination: destination)
70
+ self
71
+ end
72
+
73
+ def normalize_container_path(path, label)
74
+ value = path.to_s
75
+ clean = Pathname.new(value).cleanpath.to_s
76
+ return clean if value.start_with?("/") && clean == value
77
+
78
+ raise ConfigurationError, "Artifact #{label} must be a clean absolute path: #{path.inspect}"
79
+ end
80
+
81
+ def source_for(entry, client, container)
82
+ source = entry.source.to_s
83
+ owner = entry.origin == :container ? container : client.host
84
+ entry.kind == :file ? owner.file(source) : owner.directory(source)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class BoringCache
5
+ CLI_IMAGE = "ghcr.io/boringcache/base:bookworm-v1.19.4@" \
6
+ "sha256:84d1e168a50fd086ecd4fdb2fd96f689982a6f3c61454acd8c7189e336f8f3ae"
7
+ BUILD_IMAGE = "ghcr.io/boringcache/base:bookworm-build-v1.19.4@" \
8
+ "sha256:df4461581214c0052157041238a67b8da420e1399e4024bc857c68c74a9721cb"
9
+ TOKEN_NAMES = %w[BORINGCACHE_RESTORE_TOKEN BORINGCACHE_SAVE_TOKEN].freeze
10
+ ENVIRONMENT_NAMES = %w[BORINGCACHE_API_URL BORINGCACHE_DEFAULT_WORKSPACE BORINGCACHE_WORKSPACE].freeze
11
+
12
+ attr_reader :project, :client, :environment
13
+
14
+ def initialize(project, client, environment: ENV)
15
+ @project = project
16
+ @client = client
17
+ @environment = environment
18
+ end
19
+
20
+ def enabled?
21
+ token? && (workspace? || project_config?)
22
+ end
23
+
24
+ def prepare(container, workdir: project.application_path)
25
+ return container unless enabled?
26
+
27
+ container = container.with_file("/usr/local/bin/boringcache", cli_file, permissions: 0o755)
28
+ container = container.with_file("#{workdir}/.boringcache.toml", project_config_file) if project_config?
29
+ container = with_environment(container)
30
+ with_secrets(container)
31
+ end
32
+
33
+ def wrap(command, entry: "bundler")
34
+ return command unless enabled?
35
+
36
+ wrapped(command, "--entry", entry)
37
+ end
38
+
39
+ def wrap_mount(command, tag:, path:)
40
+ wrap_mounts(command, tag => path)
41
+ end
42
+
43
+ def wrap_mounts(command, mounts)
44
+ return command unless enabled?
45
+
46
+ cache_arguments = mounts.flat_map { |tag, path| ["--manual-entry", "#{tag}:#{path}"] }
47
+ wrapped(command, *cache_arguments)
48
+ end
49
+
50
+ def mode
51
+ enabled? ? :boringcache : :local
52
+ end
53
+
54
+ def artifact_publishable?
55
+ save_token? && (workspace? || project_config?)
56
+ end
57
+
58
+ def prepare_artifact_publisher(container, workdir: "/workspace")
59
+ container = container.with_workdir(workdir)
60
+ container = container.with_file("#{workdir}/.boringcache.toml", project_config_file) if project_config?
61
+ container = with_environment(container)
62
+ with_secrets(container)
63
+ end
64
+
65
+ def cli_image
66
+ environment.fetch("BORINGBUILDER_BORINGCACHE_IMAGE", CLI_IMAGE)
67
+ end
68
+
69
+ def save_token?
70
+ !value("BORINGCACHE_SAVE_TOKEN").nil?
71
+ end
72
+
73
+ def workspace_configured?
74
+ workspace? || project_config?
75
+ end
76
+
77
+ private
78
+
79
+ def wrapped(command, *cache_arguments)
80
+ arguments = ["boringcache", "run", *cache_arguments, "--no-git"]
81
+ arguments << "--read-only" unless save_token?
82
+ [*arguments, "--", *command]
83
+ end
84
+
85
+ def token?
86
+ TOKEN_NAMES.any? { |name| value(name) }
87
+ end
88
+
89
+ def workspace?
90
+ %w[BORINGCACHE_DEFAULT_WORKSPACE BORINGCACHE_WORKSPACE].any? { |name| value(name) }
91
+ end
92
+
93
+ def project_config?
94
+ project.root.join(".boringcache.toml").file?
95
+ end
96
+
97
+ def project_config_file
98
+ project.source_file(client, ".boringcache.toml")
99
+ end
100
+
101
+ def cli_file
102
+ options = project.configuration.platform ? { platform: project.configuration.platform } : {}
103
+ client.container(options).from(cli_image).file("/usr/local/bin/boringcache")
104
+ end
105
+
106
+ def with_environment(container)
107
+ ENVIRONMENT_NAMES.reduce(container) do |result, name|
108
+ value(name) ? result.with_env_variable(name, value(name)) : result
109
+ end
110
+ end
111
+
112
+ def with_secrets(container)
113
+ TOKEN_NAMES.reduce(container) do |result, name|
114
+ value(name) ? result.with_secret_variable(name, secret(name)) : result
115
+ end
116
+ end
117
+
118
+ def secret(name)
119
+ client.set_secret(name, value(name))
120
+ end
121
+
122
+ def value(name)
123
+ environment[name].to_s.empty? ? nil : environment[name]
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class BuildProgress
5
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
6
+
7
+ def self.silent
8
+ @silent ||= new(enabled: false)
9
+ end
10
+
11
+ def initialize(out: $stdout, enabled: true, animated: false)
12
+ @out = out
13
+ @enabled = enabled
14
+ @animated = animated
15
+ @step = 0
16
+ end
17
+
18
+ def step(name)
19
+ return yield unless @enabled
20
+
21
+ number = next_step
22
+ started_at = monotonic_time
23
+ @out.puts "##{number} #{name}"
24
+ @active_spinner = start_spinner(number, started_at)
25
+ result = yield
26
+ stop_active_spinner
27
+ @out.puts "##{number} DONE #{elapsed_since(started_at)}"
28
+ result
29
+ rescue StandardError
30
+ stop_active_spinner
31
+ @out.puts "##{number} ERROR #{elapsed_since(started_at)}" if number
32
+ raise
33
+ ensure
34
+ stop_active_spinner
35
+ end
36
+
37
+ def write(output = nil)
38
+ return unless @enabled
39
+
40
+ output = yield if block_given?
41
+ stop_active_spinner
42
+ text = output.to_s
43
+ text.each_line { |line| @out.print " #{line}" }
44
+ @out.puts unless text.empty? || text.end_with?("\n")
45
+ end
46
+
47
+ private
48
+
49
+ def next_step
50
+ @step += 1
51
+ end
52
+
53
+ def start_spinner(number, started_at)
54
+ return unless @animated
55
+
56
+ Thread.new do
57
+ frame = 0
58
+ loop do
59
+ @out.print "\r##{number} #{SPINNER_FRAMES.fetch(frame % SPINNER_FRAMES.length)} #{elapsed_since(started_at)}"
60
+ @out.flush
61
+ frame += 1
62
+ sleep 0.1
63
+ end
64
+ rescue IOError
65
+ nil
66
+ end
67
+ end
68
+
69
+ def stop_spinner(thread)
70
+ return unless thread&.alive?
71
+
72
+ thread.kill
73
+ thread.join
74
+ @out.print "\r\e[2K"
75
+ end
76
+
77
+ def stop_active_spinner
78
+ stop_spinner(@active_spinner)
79
+ @active_spinner = nil
80
+ end
81
+
82
+ def monotonic_time
83
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ end
85
+
86
+ def elapsed_since(started_at)
87
+ format("%.1fs", monotonic_time - started_at)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dagger_ruby"
4
+
5
+ module BoringBuilder
6
+ class Builder
7
+ attr_reader :configuration, :progress
8
+
9
+ def initialize(configuration, progress: BuildProgress.silent)
10
+ @configuration = configuration
11
+ @progress = progress
12
+ end
13
+
14
+ def build
15
+ project = Project.new(configuration).validate!
16
+ runtime = Runtime.new(configuration.runtime)
17
+ resolved_runtime = runtime.resolve
18
+ dagger_configuration = DaggerRuby::Config.new(
19
+ runtime: runtime.dagger_runtime(resolved_runtime),
20
+ progress: configuration.progress,
21
+ silent: configuration.progress.nil?
22
+ )
23
+
24
+ DaggerRuby.connection(dagger_configuration) do |client|
25
+ container = project.container(client, progress: progress)
26
+ Exporter.new(project, client, container, runtime: resolved_runtime, progress: progress).call
27
+ end
28
+ rescue DaggerRuby::DaggerError => e
29
+ message = e.message.sub(/\s*\[traceparent:[^\]]+\]\s*\z/, "")
30
+ raise BuildError, "Dagger build failed: #{message}"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "pathname"
6
+
7
+ module BoringBuilder
8
+ class CLI
9
+ def self.start(argv = ARGV, **)
10
+ new(argv, **).start
11
+ end
12
+
13
+ def initialize(argv, out: $stdout, err: $stderr, builder_class: Builder, environment: ENV)
14
+ @argv = argv.dup
15
+ @out = out
16
+ @err = err
17
+ @builder_class = builder_class
18
+ @environment = environment
19
+ end
20
+
21
+ def run
22
+ return root_help if @argv.empty? || %w[help --help -h].include?(@argv.first)
23
+ return version if %w[version --version -v].include?(@argv.first)
24
+
25
+ command = @argv.shift
26
+ case command
27
+ when "build" then build
28
+ when "doctor" then doctor
29
+ when "init" then initialize_project
30
+ else raise ConfigurationError, "Unknown command '#{command}'. Use build, doctor, init, or version."
31
+ end
32
+ end
33
+
34
+ def start
35
+ run
36
+ rescue BoringBuilder::Error, OptionParser::ParseError => e
37
+ @err.puts "boringbuilder: #{e.message}"
38
+ 1
39
+ end
40
+
41
+ private
42
+
43
+ def version
44
+ @out.puts BoringBuilder::VERSION
45
+ 0
46
+ end
47
+
48
+ def root_help
49
+ @out.puts <<~HELP
50
+ Usage: boringbuilder COMMAND [options]
51
+
52
+ Commands:
53
+ build [PROJECT] Build and export a deployment artifact
54
+ doctor Check the selected container runtime
55
+ init [PROJECT] Generate a Dagger Ruby build recipe
56
+ version Print the gem version
57
+ HELP
58
+ 0
59
+ end
60
+
61
+ def build
62
+ options = {}
63
+ config_file = :auto
64
+ dry_run = false
65
+ json = false
66
+ parser = build_parser(options, ->(value) { config_file = value }, -> { dry_run = true }) { json = true }
67
+ help_requested = catch(:boringbuilder_help) do
68
+ parser.parse!(@argv)
69
+ false
70
+ end
71
+ return 0 if help_requested
72
+ raise OptionParser::InvalidArgument, "expected at most one PROJECT directory" if @argv.length > 1
73
+
74
+ configuration = BoringBuilder.configuration(root: @argv.first || Dir.pwd, config: config_file, **options)
75
+ project = Project.new(configuration).validate!
76
+ if dry_run
77
+ @out.puts JSON.pretty_generate(project.plan)
78
+ return 0
79
+ end
80
+
81
+ print_build_start(project, json: json)
82
+ result = @builder_class.new(configuration, progress: build_progress(configuration, json: json)).build
83
+ json ? @out.puts(JSON.generate(result.to_h)) : print_result(result)
84
+ 0
85
+ end
86
+
87
+ def build_parser(options, config_file, dry_run, &json)
88
+ OptionParser.new do |parser|
89
+ parser.banner = "Usage: boringbuilder build [options] [PROJECT]"
90
+ add_runtime_options(parser, options)
91
+ add_build_options(parser, options)
92
+ add_export_options(parser, options)
93
+ add_rails_options(parser, options)
94
+ parser.on("--config FILE", "Load a Ruby build recipe (default: config/boringbuilder.rb)") do |value|
95
+ config_file.call(value)
96
+ end
97
+ parser.on("--no-config", "Ignore config/boringbuilder.rb") { config_file.call(false) }
98
+ parser.on("--dry-run", "Print the resolved build plan") { dry_run.call }
99
+ parser.on("--json", "Print the build result as JSON") { json.call }
100
+ parser.on("-h", "--help", "Show this help") do
101
+ @out.puts(parser)
102
+ throw :boringbuilder_help, true
103
+ end
104
+ end
105
+ end
106
+
107
+ def add_runtime_options(parser, options)
108
+ parser.on("--runtime NAME", "auto, apple, or docker") { |value| options[:runtime] = value }
109
+ parser.on("--platform PLATFORM", "Target platform, for example linux/amd64") do |value|
110
+ options[:platform] = value
111
+ end
112
+ parser.on("--progress MODE", %w[auto plain tty], "Dagger progress: auto, plain, or tty") do |value|
113
+ options[:progress] = value
114
+ end
115
+ end
116
+
117
+ def add_build_options(parser, options)
118
+ parser.on("--command COMMAND", "Override the built-in Ruby image command") { |value| options[:command] = value }
119
+ parser.on("--port PORT", Integer, "Expose PORT for a built-in Ruby web application") do |value|
120
+ options[:port] = value
121
+ end
122
+ end
123
+
124
+ def add_export_options(parser, options)
125
+ parser.on("--format FORMAT", "directory, tar, tar.zst, oci, or docker") { |value| options[:format] = value }
126
+ parser.on("-o", "--output PATH", "Local artifact destination") { |value| options[:output] = value }
127
+ parser.on("--path SOURCE[=DESTINATION]",
128
+ "Export a container directory, optionally remapped (repeatable)") do |value|
129
+ (options[:paths] ||= []) << value
130
+ end
131
+ parser.on("--file SOURCE[=DESTINATION]", "Export a container file, optionally remapped (repeatable)") do |value|
132
+ (options[:files] ||= []) << value
133
+ end
134
+ parser.on("--host-path SOURCE=DESTINATION", "Add an explicit host file or directory (repeatable)") do |value|
135
+ (options[:host_paths] ||= []) << value
136
+ end
137
+ parser.on("--push IMAGE", "Publish the image to a registry") { |value| options[:publish] = value }
138
+ parser.on("--load IMAGE", "Load the image into the selected runtime") { |value| options[:load] = value }
139
+ parser.on("--exporter NAME", "Artifact exporter: auto, local, or boringcache") do |value|
140
+ options[:exporter] = value
141
+ end
142
+ parser.on("--artifact", "Use the BoringCache Artifact exporter") { options[:exporter] = :boringcache }
143
+ parser.on("--no-artifact", "Use the local artifact exporter") { options[:exporter] = :local }
144
+ parser.on("--artifact-name NAME", "Name the BoringCache Artifact") { |value| options[:artifact_name] = value }
145
+ end
146
+
147
+ def add_rails_options(parser, options)
148
+ parser.on("--base-image IMAGE", "Base image for the built-in Rails build") do |value|
149
+ options[:base_image] = value
150
+ end
151
+ parser.on("--[no-]assets", "Enable or disable Rails asset precompilation") { |value| options[:assets] = value }
152
+ parser.on("--[no-]bootsnap", "Enable or disable Bootsnap precompilation") { |value| options[:bootsnap] = value }
153
+ end
154
+
155
+ def doctor
156
+ options = { runtime: :auto }
157
+ parser = OptionParser.new do |command|
158
+ command.banner = "Usage: boringbuilder doctor [--runtime NAME]"
159
+ command.on("--runtime NAME", "auto, apple, or docker") { |value| options[:runtime] = value }
160
+ command.on("-h", "--help", "Show this help") do
161
+ @out.puts(command)
162
+ throw :boringbuilder_help, true
163
+ end
164
+ end
165
+ help_requested = catch(:boringbuilder_help) do
166
+ parser.parse!(@argv)
167
+ false
168
+ end
169
+ return 0 if help_requested
170
+ raise OptionParser::InvalidArgument, "doctor does not accept a PROJECT directory" unless @argv.empty?
171
+
172
+ runtime = Runtime.new(options[:runtime]).resolve
173
+ @out.puts "Dagger runtime ready: #{runtime}"
174
+ 0
175
+ end
176
+
177
+ def initialize_project
178
+ options = { template: :auto, force: false }
179
+ parser = OptionParser.new do |command|
180
+ command.banner = "Usage: boringbuilder init [options] [PROJECT]"
181
+ command.on("--template NAME", Initializer::TEMPLATES.map(&:to_s),
182
+ "Recipe template: auto, ruby, node, rust, go, or generic") do |value|
183
+ options[:template] = value
184
+ end
185
+ command.on("--force", "Replace an existing config/boringbuilder.rb") { options[:force] = true }
186
+ command.on("-h", "--help", "Show this help") do
187
+ @out.puts(command)
188
+ throw :boringbuilder_help, true
189
+ end
190
+ end
191
+ help_requested = catch(:boringbuilder_help) do
192
+ parser.parse!(@argv)
193
+ false
194
+ end
195
+ return 0 if help_requested
196
+ raise OptionParser::InvalidArgument, "expected at most one PROJECT directory" if @argv.length > 1
197
+
198
+ root = Pathname.new(@argv.first || Dir.pwd).expand_path
199
+ initializer = Initializer.new(root, template: options[:template])
200
+ path = initializer.call(force: options[:force])
201
+ @out.puts "Created #{path.relative_path_from(root)} (#{initializer.template_name})"
202
+ 0
203
+ end
204
+
205
+ def print_result(result)
206
+ @out.puts "Built with Dagger on #{result.runtime}"
207
+ @out.puts "Exported #{result.format}: #{result.path}" if result.exported?
208
+ @out.puts "Image: #{result.reference}" if result.published?
209
+ @out.puts "Artifact: #{result.artifact_id} (#{result.artifact_name})" if result.artifact_published?
210
+ end
211
+
212
+ def print_build_start(project, json:)
213
+ return if json || !dagger_session?
214
+
215
+ cache = BoringCache.new(project, nil, environment: @environment).mode
216
+ pipeline = project.custom_pipeline? ? "custom pipeline" : project.framework.to_s
217
+ details = [pipeline, "#{project.configuration.runtime} runtime", "#{cache} cache",
218
+ "#{project.configuration.format.to_s.tr('_', '.')} output"]
219
+
220
+ @out.puts "Building #{project.app_name} with Dagger"
221
+ @out.puts " #{details.join(' · ')}"
222
+ end
223
+
224
+ def dagger_session?
225
+ @environment["DAGGER_SESSION_PORT"] && @environment["DAGGER_SESSION_TOKEN"]
226
+ end
227
+
228
+ def build_progress(configuration, json:)
229
+ enabled = !json && dagger_session? && configuration.progress.nil?
230
+ animated = enabled && @out.respond_to?(:tty?) && @out.tty?
231
+ BuildProgress.new(out: @out, enabled: enabled, animated: animated)
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class ConfigFile
5
+ DEFAULT_PATH = "config/boringbuilder.rb"
6
+ THREAD_KEY = :boringbuilder_config_file
7
+
8
+ class << self
9
+ def resolve(root, requested = :auto)
10
+ return if requested == false || requested.nil?
11
+
12
+ path = root.join(requested == :auto ? DEFAULT_PATH : requested).expand_path
13
+ return path if path.file?
14
+ return if requested == :auto
15
+
16
+ raise ConfigurationError, "BoringBuilder config file does not exist: #{path}"
17
+ end
18
+
19
+ def load(path, configuration)
20
+ previous = Thread.current[THREAD_KEY]
21
+ state = { configuration: configuration, applied: false }
22
+ Thread.current[THREAD_KEY] = state
23
+ Kernel.load(path.to_s)
24
+ raise ConfigurationError, "#{path} must call BoringBuilder.configure" unless state[:applied]
25
+
26
+ configuration
27
+ ensure
28
+ Thread.current[THREAD_KEY] = previous
29
+ end
30
+
31
+ def configure
32
+ state = Thread.current[THREAD_KEY]
33
+ raise ConfigurationError, "BoringBuilder.configure is only available while loading a config file" unless state
34
+
35
+ state[:applied] = true
36
+ yield state.fetch(:configuration)
37
+ end
38
+ end
39
+ end
40
+ end