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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module BoringBuilder
6
+ class Configuration
7
+ FORMATS = %i[directory tar tar_zst oci docker].freeze
8
+ RUNTIMES = %i[auto apple docker].freeze
9
+ FORMAT_ALIASES = {
10
+ "tar.zst" => :tar_zst,
11
+ "tar-zst" => :tar_zst,
12
+ "tar_zst" => :tar_zst
13
+ }.freeze
14
+
15
+ attr_accessor :root, :runtime, :platform, :format, :output,
16
+ :base_image, :paths, :files, :host_paths, :publish, :load,
17
+ :progress, :assets, :bootsnap, :artifact,
18
+ :command, :port, :exporter, :artifact_name,
19
+ :build_packages, :runtime_packages, :build_environment
20
+ attr_writer :pipeline
21
+
22
+ def initialize(root: Dir.pwd, runtime: :auto, platform: nil, format: :tar_zst,
23
+ output: nil, base_image: nil, paths: nil,
24
+ files: nil, host_paths: nil, publish: nil, load: nil,
25
+ progress: nil, assets: nil, bootsnap: nil,
26
+ artifact: nil, command: nil, port: nil,
27
+ exporter: :auto, artifact_name: nil, pipeline: nil,
28
+ build_packages: nil, runtime_packages: nil, build_environment: nil)
29
+ @root = Pathname.new(root).expand_path
30
+ @runtime = normalize_runtime(runtime)
31
+ @platform = platform
32
+ @format = normalize_format(format)
33
+ @output = output && Pathname.new(output).expand_path(@root)
34
+ @base_image = base_image
35
+ @paths = Array(paths).compact
36
+ @files = Array(files).compact
37
+ @host_paths = Array(host_paths).compact
38
+ @publish = publish
39
+ @load = load
40
+ @progress = progress
41
+ @assets = assets
42
+ @bootsnap = bootsnap
43
+ @artifact = artifact || Artifact.new(root: @root)
44
+ @command = command
45
+ @port = port
46
+ @exporter = normalize_exporter(exporter)
47
+ @artifact_name = artifact_name
48
+ @pipeline = pipeline
49
+ @build_packages = normalize_packages(build_packages)
50
+ @runtime_packages = normalize_packages(runtime_packages)
51
+ @build_environment = normalize_environment(build_environment)
52
+ end
53
+
54
+ def validate!
55
+ normalize!
56
+ validate_project_directory!
57
+ validate_option_combinations!
58
+ validate_port!
59
+ validate_artifact!
60
+ validate_output_path!
61
+ self
62
+ end
63
+
64
+ def apply(options)
65
+ options.each do |name, value|
66
+ writer = "#{name}="
67
+ raise ConfigurationError, "Unknown configuration option: #{name}" unless respond_to?(writer)
68
+
69
+ public_send(writer, value)
70
+ end
71
+ self
72
+ end
73
+
74
+ def pipeline(&block)
75
+ @pipeline = block if block
76
+ @pipeline
77
+ end
78
+
79
+ private
80
+
81
+ def validate_project_directory!
82
+ raise ConfigurationError, "Project directory does not exist: #{root}" unless root.directory?
83
+ end
84
+
85
+ def validate_option_combinations!
86
+ raise ConfigurationError, "--push and --load cannot be used together" if publish && load
87
+ end
88
+
89
+ def validate_port!
90
+ raise ConfigurationError, "port must be a positive integer" if port && port <= 0
91
+ end
92
+
93
+ def validate_artifact!
94
+ raise ConfigurationError, "artifact must be a BoringBuilder::Artifact" unless artifact.is_a?(Artifact)
95
+ raise ConfigurationError, "pipeline must be callable" if pipeline && !pipeline.respond_to?(:call)
96
+ end
97
+
98
+ def normalize!
99
+ @root = Pathname.new(root).expand_path
100
+ @runtime = normalize_runtime(runtime)
101
+ @format = normalize_format(format)
102
+ @output = output && Pathname.new(output).expand_path(root)
103
+ normalize_collection_options!
104
+ normalize_build_options!
105
+ end
106
+
107
+ def normalize_collection_options!
108
+ @paths = Array(paths).compact
109
+ @files = Array(files).compact
110
+ @host_paths = Array(host_paths).compact
111
+ end
112
+
113
+ def normalize_build_options!
114
+ @port = normalize_port(port) if port
115
+ @exporter = normalize_exporter(exporter)
116
+ @artifact_name = artifact_name&.to_s
117
+ @build_packages = normalize_packages(build_packages)
118
+ @runtime_packages = normalize_packages(runtime_packages)
119
+ @build_environment = normalize_environment(build_environment)
120
+ end
121
+
122
+ def normalize_runtime(value)
123
+ runtime = value.to_s.downcase.to_sym
124
+ return runtime if RUNTIMES.include?(runtime)
125
+
126
+ raise ConfigurationError, "Unknown runtime '#{value}'. Use auto, apple, or docker."
127
+ end
128
+
129
+ def normalize_format(value)
130
+ format = FORMAT_ALIASES.fetch(value.to_s, value.to_s.downcase.to_sym)
131
+ return format if FORMATS.include?(format)
132
+
133
+ raise ConfigurationError, "Unknown format '#{value}'. Use directory, tar, tar.zst, oci, or docker."
134
+ end
135
+
136
+ def normalize_port(value)
137
+ Integer(value)
138
+ rescue ArgumentError, TypeError
139
+ raise ConfigurationError, "port must be a positive integer"
140
+ end
141
+
142
+ def normalize_exporter(value)
143
+ exporter = (value || :auto).to_sym
144
+ return exporter if %i[auto local boringcache].include?(exporter)
145
+
146
+ raise ConfigurationError, "exporter must be auto, local, or boringcache"
147
+ end
148
+
149
+ def normalize_packages(packages)
150
+ Array(packages).compact.map(&:to_s)
151
+ end
152
+
153
+ def normalize_environment(environment)
154
+ (environment || {}).to_h { |name, value| [name.to_s, value.to_s] }
155
+ end
156
+
157
+ def validate_output_path!
158
+ return unless output
159
+
160
+ unsafe = output == Pathname.new("/") || output == root || output == Pathname.new(Dir.home).expand_path
161
+ unsafe ||= format == :directory && root.to_s.start_with?("#{output}/")
162
+ raise ConfigurationError, "Refusing unsafe output path: #{output}" if unsafe
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class RuntimeError < Error; end
7
+ class BuildError < Error; end
8
+ class ExportError < Error; end
9
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+
6
+ module BoringBuilder
7
+ class Exporter
8
+ PACKER_IMAGE = "alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"
9
+
10
+ attr_reader :project, :client, :container, :runtime, :exporters, :progress
11
+
12
+ def initialize(project, client, container, runtime:, command_runner: nil, exporters: nil,
13
+ progress: BuildProgress.silent)
14
+ @project = project
15
+ @client = client
16
+ @container = container
17
+ @runtime = runtime
18
+ @command_runner = command_runner || ->(*command) { Open3.capture3(*command) }
19
+ @exporters = exporters || Exporters::Resolver.new(project, client).destinations
20
+ @progress = progress
21
+ end
22
+
23
+ def call
24
+ exporters.each(&:validate!)
25
+ asset = build_asset unless exporters.empty?
26
+ receipt = exporters.reduce(Exporters::Receipt.new) do |result, adapter|
27
+ received = progress.step(export_step_name(adapter)) { adapter.export(asset) }
28
+ merge_receipts(result, received)
29
+ end
30
+ reference = export_reference
31
+ Result.new(
32
+ format: configuration.format,
33
+ path: receipt.path,
34
+ reference: reference,
35
+ runtime: runtime,
36
+ platform: configuration.platform,
37
+ artifact_id: receipt.artifact_id,
38
+ artifact_name: receipt.artifact_name
39
+ )
40
+ rescue DaggerRuby::DaggerError => e
41
+ raise ExportError, "Dagger could not export the build: #{e.message}"
42
+ end
43
+
44
+ private
45
+
46
+ def configuration
47
+ project.configuration
48
+ end
49
+
50
+ def merge_receipts(current, received)
51
+ Exporters::Receipt.new(
52
+ path: received.path || current.path,
53
+ artifact_id: received.artifact_id || current.artifact_id,
54
+ artifact_name: received.artifact_name || current.artifact_name
55
+ )
56
+ end
57
+
58
+ def export_step_name(adapter)
59
+ return "Publish artifact to BoringCache" if adapter.name == :boringcache
60
+
61
+ "Export #{configuration.format.to_s.tr('_', '.')} artifact"
62
+ end
63
+
64
+ def build_asset
65
+ filename = project.output_path.basename.to_s
66
+ case configuration.format
67
+ when :directory
68
+ Exporters::Asset.new(kind: :directory, source: artifact_directory, filename: filename)
69
+ when :tar
70
+ Exporters::Asset.new(kind: :file, source: archive_file("artifact.tar", compression: nil), filename: filename)
71
+ when :tar_zst
72
+ Exporters::Asset.new(kind: :file, source: archive_file("artifact.tar.zst", compression: :zstd),
73
+ filename: filename)
74
+ when :oci, :docker
75
+ Exporters::Asset.new(kind: :file, source: container.as_tarball(media_types: media_types), filename: filename)
76
+ end
77
+ end
78
+
79
+ def export_reference
80
+ if configuration.publish
81
+ progress.step("Publish container image") do
82
+ container.publish(configuration.publish, media_types: media_types)
83
+ end
84
+ elsif configuration.load
85
+ progress.step("Load container image") { load_image }
86
+ end
87
+ end
88
+
89
+ def load_image
90
+ return load_apple_image if runtime == :apple
91
+
92
+ container.export_image(configuration.load, media_types: media_types)
93
+ configuration.load
94
+ end
95
+
96
+ def load_apple_image
97
+ Dir.mktmpdir("boringbuilder-load") do |directory|
98
+ archive = File.join(directory, "image.oci.tar")
99
+ container.export(archive, media_types: :OCIMediaTypes)
100
+ source = load_apple_archive(archive)
101
+ run_apple_command("container", "image", "tag", source, configuration.load)
102
+ end
103
+ configuration.load
104
+ end
105
+
106
+ def load_apple_archive(archive)
107
+ output = run_apple_command("container", "image", "load", "--input", archive)
108
+ source = output[/\b(?:untagged@)?sha256:[0-9a-f]{64}\b/]
109
+ return source if source
110
+
111
+ raise ExportError, "Apple Container loaded the image without returning a taggable digest"
112
+ end
113
+
114
+ def run_apple_command(*command)
115
+ stdout, stderr, status = @command_runner.call(*command)
116
+ output = [stdout, stderr].map(&:strip).reject(&:empty?).join("\n")
117
+ return output if status.success?
118
+
119
+ raise ExportError, "#{command.first(3).join(' ')} failed: #{output}"
120
+ end
121
+
122
+ def artifact_directory
123
+ @artifact_directory ||= project.artifact.export_directory(client, container)
124
+ end
125
+
126
+ def archive_file(filename, compression:)
127
+ packer = client.container.from(PACKER_IMAGE).with_exec(%w[apk add --no-cache tar zstd])
128
+ packer = packer.with_mounted_directory("/artifact", artifact_directory)
129
+ tar_path = compression == :zstd ? "/artifact.tar" : "/#{filename}"
130
+ packer = packer.with_exec(
131
+ [
132
+ "tar", "--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner",
133
+ "--pax-option=delete=atime,delete=ctime", "-C", "/artifact", "-cf", tar_path, "."
134
+ ]
135
+ )
136
+ packer = packer.with_exec(["zstd", "-q", "-T1", tar_path, "-o", "/#{filename}"]) if compression == :zstd
137
+ packer.file("/#{filename}")
138
+ end
139
+
140
+ def media_types
141
+ configuration.format == :docker ? :DockerMediaTypes : :OCIMediaTypes
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ module Exporters
5
+ Asset = Data.define(:kind, :source, :filename)
6
+ end
7
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BoringBuilder
6
+ module Exporters
7
+ class BoringCache
8
+ attr_reader :project, :client, :cache
9
+
10
+ def initialize(project, client, environment: ENV)
11
+ @project = project
12
+ @client = client
13
+ @cache = ::BoringBuilder::BoringCache.new(project, client, environment: environment)
14
+ end
15
+
16
+ def name = :boringcache
17
+
18
+ def validate!
19
+ if configuration.artifact_name&.strip == ""
20
+ raise ConfigurationError, "BoringCache Artifact name cannot be empty"
21
+ end
22
+ raise ConfigurationError, "The boringcache exporter requires BORINGCACHE_SAVE_TOKEN" unless cache.save_token?
23
+ return self if cache.workspace_configured?
24
+
25
+ raise ConfigurationError,
26
+ "The boringcache exporter requires a workspace environment variable or .boringcache.toml"
27
+ end
28
+
29
+ def export(asset)
30
+ validate!
31
+ publisher = client.container.from(cache.cli_image)
32
+ publisher = cache.prepare_artifact_publisher(publisher)
33
+ path, publisher = mount_asset(publisher, asset)
34
+ output = publisher.with_exec(publish_command(path, asset.kind)).stdout
35
+ artifact = JSON.parse(output).fetch("artifact")
36
+ validate_artifact!(artifact)
37
+ Receipt.new(artifact_id: artifact.fetch("id"), artifact_name: artifact.fetch("name"))
38
+ rescue JSON::ParserError, KeyError => e
39
+ raise ExportError, "BoringCache Artifact returned an invalid receipt: #{e.message}"
40
+ end
41
+
42
+ private
43
+
44
+ def configuration
45
+ project.configuration
46
+ end
47
+
48
+ def artifact_name
49
+ configuration.artifact_name || [project.app_name, platform_name, format_name].join("-")
50
+ end
51
+
52
+ def platform_name
53
+ (configuration.platform || "native").tr("/", "-")
54
+ end
55
+
56
+ def format_name
57
+ configuration.format.to_s.tr("_", "-")
58
+ end
59
+
60
+ def mount_asset(publisher, asset)
61
+ if asset.kind == :directory
62
+ ["/artifact", publisher.with_mounted_directory("/artifact", asset.source)]
63
+ else
64
+ path = "/artifact/#{asset.filename}"
65
+ [path, publisher.with_file(path, asset.source)]
66
+ end
67
+ end
68
+
69
+ def publish_command(path, kind)
70
+ command = ["boringcache", "artifact", "push", path, "--name", artifact_name,
71
+ "--include-hidden", "--json"]
72
+ command.push("--compression", "none") unless kind == :directory
73
+ command
74
+ end
75
+
76
+ def validate_artifact!(artifact)
77
+ return if artifact.fetch("id", "").start_with?("art_") && artifact.fetch("status", nil) == "ready"
78
+
79
+ raise ExportError, "BoringCache Artifact did not return a ready artifact"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module BoringBuilder
6
+ module Exporters
7
+ class Local
8
+ attr_reader :project
9
+
10
+ def initialize(project)
11
+ @project = project
12
+ end
13
+
14
+ def name = :local
15
+
16
+ def validate! = self
17
+
18
+ def export(asset)
19
+ output = project.output_path
20
+ FileUtils.mkdir_p(output.dirname)
21
+ options = asset.kind == :directory ? { wipe: true } : {}
22
+ asset.source.export(output.to_s, **options)
23
+ Receipt.new(path: output.to_s)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ module Exporters
5
+ Receipt = Data.define(:path, :artifact_id, :artifact_name) do
6
+ def initialize(path: nil, artifact_id: nil, artifact_name: nil)
7
+ super
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ module Exporters
5
+ class Resolver
6
+ ADAPTERS = {
7
+ local: Local,
8
+ boringcache: BoringCache
9
+ }.freeze
10
+
11
+ attr_reader :project, :client, :cache
12
+
13
+ def initialize(project, client, environment: ENV)
14
+ @project = project
15
+ @client = client
16
+ @environment = environment
17
+ @cache = ::BoringBuilder::BoringCache.new(project, client, environment: environment)
18
+ end
19
+
20
+ def destinations
21
+ names = [primary_name].compact
22
+ names << :local if configuration.output && !names.include?(:local)
23
+ names.map { |name| adapter(name) }
24
+ end
25
+
26
+ def plan
27
+ destinations.map(&:name)
28
+ end
29
+
30
+ def validate!
31
+ destinations.each(&:validate!)
32
+ self
33
+ end
34
+
35
+ private
36
+
37
+ def configuration
38
+ project.configuration
39
+ end
40
+
41
+ def primary_name
42
+ return configuration.exporter unless configuration.exporter == :auto
43
+ return :boringcache if cache.artifact_publishable?
44
+ return if configuration.publish || configuration.load
45
+
46
+ :local
47
+ end
48
+
49
+ def adapter(name)
50
+ adapter = ADAPTERS.fetch(name)
51
+ return adapter.new(project) if name == :local
52
+
53
+ adapter.new(project, client, environment: @environment)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "exporters/asset"
4
+ require_relative "exporters/receipt"
5
+ require_relative "exporters/local"
6
+ require_relative "exporters/boring_cache"
7
+ require_relative "exporters/resolver"
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module BoringBuilder
7
+ class Initializer
8
+ TEMPLATES = %i[auto ruby node rust go generic].freeze
9
+
10
+ attr_reader :root, :requested_template
11
+
12
+ def initialize(root, template: :auto)
13
+ @root = Pathname.new(root).expand_path
14
+ @requested_template = template.to_s.downcase.to_sym
15
+ end
16
+
17
+ def call(force: false)
18
+ validate!
19
+ raise ConfigurationError, "#{path} already exists; pass --force to replace it" if path.exist? && !force
20
+
21
+ FileUtils.mkdir_p(path.dirname)
22
+ path.write(template)
23
+ path
24
+ end
25
+
26
+ def template_name
27
+ return requested_template unless requested_template == :auto
28
+ return :ruby if root.join("Gemfile").file?
29
+ return :node if root.join("package.json").file?
30
+ return :rust if root.join("Cargo.toml").file?
31
+ return :go if root.join("go.mod").file?
32
+
33
+ :generic
34
+ end
35
+
36
+ private
37
+
38
+ def path
39
+ root.join(ConfigFile::DEFAULT_PATH)
40
+ end
41
+
42
+ def validate!
43
+ raise ConfigurationError, "Project directory does not exist: #{root}" unless root.directory?
44
+ return if TEMPLATES.include?(requested_template)
45
+
46
+ raise ConfigurationError, "Unknown template '#{requested_template}'. Use auto, ruby, node, rust, go, or generic."
47
+ end
48
+
49
+ def template
50
+ send("#{template_name}_template")
51
+ end
52
+
53
+ def ruby_template
54
+ <<~RUBY
55
+ # frozen_string_literal: true
56
+
57
+ # Rails, Hanami, Rack, and other Bundler applications use BoringBuilder's
58
+ # built-in Mise-powered pipeline. Add only the application-specific choices.
59
+ BoringBuilder.configure do |config|
60
+ # config.build_packages += %w[libexample-dev]
61
+ # config.runtime_packages += %w[libexample1]
62
+ end
63
+ RUBY
64
+ end
65
+
66
+ def node_template
67
+ install = root.join("package-lock.json").file? ? "%w[npm ci]" : "%w[npm install]"
68
+ build = node_build?
69
+ artifact = if build
70
+ 'config.artifact.directory("/app/dist", at: "/app")'
71
+ else
72
+ 'config.artifact.directory("/app")'
73
+ end
74
+ build_step = if build
75
+ "\n pipeline.exec(app, %w[npm run build], name: \"Build application\")"
76
+ else
77
+ "\n app"
78
+ end
79
+
80
+ <<~RUBY
81
+ # frozen_string_literal: true
82
+
83
+ BoringBuilder.configure do |config|
84
+ #{artifact}
85
+
86
+ config.pipeline do |pipeline|
87
+ app = pipeline.mise(tools: { node: "24" }, workdir: "/app")
88
+
89
+ app = pipeline.run(
90
+ app,
91
+ #{install},
92
+ cache: "npm-downloads",
93
+ at: "/root/.npm/_cacache",
94
+ workdir: "/app",
95
+ name: "Install dependencies"
96
+ )
97
+ #{build_step}
98
+ end
99
+ end
100
+ RUBY
101
+ end
102
+
103
+ def rust_template
104
+ <<~RUBY
105
+ # frozen_string_literal: true
106
+
107
+ BoringBuilder.configure do |config|
108
+ config.artifact.directory("/app/target/release", at: "/app")
109
+
110
+ config.pipeline do |pipeline|
111
+ app = pipeline.mise(tools: { rust: "stable" }, workdir: "/app")
112
+
113
+ pipeline.run(
114
+ app,
115
+ %w[cargo build --release],
116
+ cache: {
117
+ "cargo-registry" => "/root/.cargo/registry",
118
+ "cargo-git" => "/root/.cargo/git"
119
+ },
120
+ workdir: "/app",
121
+ name: "Build application"
122
+ )
123
+ end
124
+ end
125
+ RUBY
126
+ end
127
+
128
+ def go_template
129
+ <<~RUBY
130
+ # frozen_string_literal: true
131
+
132
+ BoringBuilder.configure do |config|
133
+ config.artifact.directory("/app/dist", at: "/app")
134
+
135
+ config.pipeline do |pipeline|
136
+ app = pipeline.mise(tools: { go: "1" }, workdir: "/app")
137
+
138
+ pipeline.run(
139
+ app,
140
+ %w[go build -o /app/dist/app .],
141
+ cache: {
142
+ "go-modules" => "/root/go/pkg/mod",
143
+ "go-build" => "/root/.cache/go-build"
144
+ },
145
+ workdir: "/app",
146
+ name: "Build application"
147
+ )
148
+ end
149
+ end
150
+ RUBY
151
+ end
152
+
153
+ def generic_template
154
+ <<~RUBY
155
+ # frozen_string_literal: true
156
+
157
+ BoringBuilder.configure do |config|
158
+ config.artifact.directory("/app")
159
+
160
+ config.pipeline do |pipeline|
161
+ # Add only the tools the application needs, then its build steps.
162
+ pipeline.mise(
163
+ tools: {
164
+ # "python" => "3"
165
+ },
166
+ workdir: "/app"
167
+ )
168
+ end
169
+ end
170
+ RUBY
171
+ end
172
+
173
+ def node_build?
174
+ scripts = JSON.parse(root.join("package.json").read)["scripts"]
175
+ scripts.is_a?(Hash) && scripts.key?("build")
176
+ rescue JSON::ParserError, TypeError
177
+ false
178
+ end
179
+ end
180
+ end