From e639f79c12fdb552daa72493379a5235b5bb0d33 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Thu, 27 Aug 2026 16:51:37 +0100 Subject: [PATCH] Improve build progress and repeatability --- CHANGELOG.md | 6 ++ Rakefile | 4 +- boringbuilder.gemspec | 2 +- lib/boringbuilder.rb | 3 +- lib/boringbuilder/build_progress.rb | 90 --------------------- lib/boringbuilder/builder.rb | 2 +- lib/boringbuilder/cli.rb | 22 ++--- lib/boringbuilder/exporter.rb | 55 ++++++++++--- lib/boringbuilder/mise.rb | 2 +- lib/boringbuilder/pipeline.rb | 7 +- lib/boringbuilder/project.rb | 3 +- lib/boringbuilder/ruby_build.rb | 99 +++++++++++++++-------- lib/boringbuilder/version.rb | 2 +- test/boringbuilder/build_progress_test.rb | 57 ------------- test/boringbuilder/exporter_test.rb | 24 +++++- test/boringbuilder/pipeline_test.rb | 2 +- test/boringbuilder/project_test.rb | 1 + test/boringbuilder/rails_build_test.rb | 29 ++++--- test/boringbuilder/ruby_build_test.rb | 17 ++++ 19 files changed, 203 insertions(+), 224 deletions(-) delete mode 100644 lib/boringbuilder/build_progress.rb delete mode 100644 test/boringbuilder/build_progress_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 054d718..ec429be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.0.alpha.2 - 2026-08-27 + +- Stream clean, Docker-style build steps and command output through Dagger Ruby. +- Run Bundler with the available CPUs unless `BUNDLE_JOBS` is explicitly configured. +- Keep Rails artifacts identical across cold and restored dependency caches. + ## 0.1.0.alpha.1 - 2026-08-27 - Reintroduce BoringBuilder as a Ruby gem backed by Dagger Ruby. diff --git a/Rakefile b/Rakefile index 0e1a02e..7f20763 100644 --- a/Rakefile +++ b/Rakefile @@ -4,7 +4,9 @@ require "bundler/gem_tasks" require "minitest/test_task" require "rubocop/rake_task" -Minitest::TestTask.create +Minitest::TestTask.create do |task| + task.framework = 'require_relative "./test/test_helper"' +end RuboCop::RakeTask.new desc "Compile every Ruby source file" diff --git a/boringbuilder.gemspec b/boringbuilder.gemspec index bf5990f..763faff 100644 --- a/boringbuilder.gemspec +++ b/boringbuilder.gemspec @@ -40,5 +40,5 @@ Gem::Specification.new do |spec| spec.executables = ["boringbuilder"] spec.require_paths = ["lib"] - spec.add_dependency "dagger_ruby", "~> 0.10" + spec.add_dependency "dagger_ruby", "~> 0.11" end diff --git a/lib/boringbuilder.rb b/lib/boringbuilder.rb index f7af1ed..cf9f65a 100644 --- a/lib/boringbuilder.rb +++ b/lib/boringbuilder.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true +require "dagger_ruby" + require_relative "boringbuilder/version" require_relative "boringbuilder/errors" require_relative "boringbuilder/artifact" require_relative "boringbuilder/configuration" require_relative "boringbuilder/config_file" require_relative "boringbuilder/initializer" -require_relative "boringbuilder/build_progress" require_relative "boringbuilder/boring_cache" require_relative "boringbuilder/mise" require_relative "boringbuilder/pipeline" diff --git a/lib/boringbuilder/build_progress.rb b/lib/boringbuilder/build_progress.rb deleted file mode 100644 index df996ea..0000000 --- a/lib/boringbuilder/build_progress.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -module BoringBuilder - class BuildProgress - SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze - - def self.silent - @silent ||= new(enabled: false) - end - - def initialize(out: $stdout, enabled: true, animated: false) - @out = out - @enabled = enabled - @animated = animated - @step = 0 - end - - def step(name) - return yield unless @enabled - - number = next_step - started_at = monotonic_time - @out.puts "##{number} #{name}" - @active_spinner = start_spinner(number, started_at) - result = yield - stop_active_spinner - @out.puts "##{number} DONE #{elapsed_since(started_at)}" - result - rescue StandardError - stop_active_spinner - @out.puts "##{number} ERROR #{elapsed_since(started_at)}" if number - raise - ensure - stop_active_spinner - end - - def write(output = nil) - return unless @enabled - - output = yield if block_given? - stop_active_spinner - text = output.to_s - text.each_line { |line| @out.print " #{line}" } - @out.puts unless text.empty? || text.end_with?("\n") - end - - private - - def next_step - @step += 1 - end - - def start_spinner(number, started_at) - return unless @animated - - Thread.new do - frame = 0 - loop do - @out.print "\r##{number} #{SPINNER_FRAMES.fetch(frame % SPINNER_FRAMES.length)} #{elapsed_since(started_at)}" - @out.flush - frame += 1 - sleep 0.1 - end - rescue IOError - nil - end - end - - def stop_spinner(thread) - return unless thread&.alive? - - thread.kill - thread.join - @out.print "\r\e[2K" - end - - def stop_active_spinner - stop_spinner(@active_spinner) - @active_spinner = nil - end - - def monotonic_time - Process.clock_gettime(Process::CLOCK_MONOTONIC) - end - - def elapsed_since(started_at) - format("%.1fs", monotonic_time - started_at) - end - end -end diff --git a/lib/boringbuilder/builder.rb b/lib/boringbuilder/builder.rb index d8667cb..2490e69 100644 --- a/lib/boringbuilder/builder.rb +++ b/lib/boringbuilder/builder.rb @@ -6,7 +6,7 @@ module BoringBuilder class Builder attr_reader :configuration, :progress - def initialize(configuration, progress: BuildProgress.silent) + def initialize(configuration, progress: DaggerRuby::Progress.silent) @configuration = configuration @progress = progress end diff --git a/lib/boringbuilder/cli.rb b/lib/boringbuilder/cli.rb index 8ba1524..d31588a 100644 --- a/lib/boringbuilder/cli.rb +++ b/lib/boringbuilder/cli.rb @@ -59,7 +59,7 @@ def root_help end def build - options = {} + options = { progress: "pretty" } config_file = :auto dry_run = false json = false @@ -71,6 +71,8 @@ def build return 0 if help_requested raise OptionParser::InvalidArgument, "expected at most one PROJECT directory" if @argv.length > 1 + options[:progress] = nil if json + configuration = BoringBuilder.configuration(root: @argv.first || Dir.pwd, config: config_file, **options) project = Project.new(configuration).validate! if dry_run @@ -78,8 +80,9 @@ def build return 0 end - print_build_start(project, json: json) - result = @builder_class.new(configuration, progress: build_progress(configuration, json: json)).build + progress = build_progress(configuration, json: json) + print_build_start(project, json: json, progress: progress) + result = @builder_class.new(configuration, progress: progress).build json ? @out.puts(JSON.generate(result.to_h)) : print_result(result) 0 end @@ -109,7 +112,8 @@ def add_runtime_options(parser, options) parser.on("--platform PLATFORM", "Target platform, for example linux/amd64") do |value| options[:platform] = value end - parser.on("--progress MODE", %w[auto plain tty], "Dagger progress: auto, plain, or tty") do |value| + parser.on("--progress MODE", %w[pretty auto plain tty dots logs], + "Build progress: pretty (default), auto, plain, tty, dots, or logs") do |value| options[:progress] = value end end @@ -209,7 +213,7 @@ def print_result(result) @out.puts "Artifact: #{result.artifact_id} (#{result.artifact_name})" if result.artifact_published? end - def print_build_start(project, json:) + def print_build_start(project, json:, progress:) return if json || !dagger_session? cache = BoringCache.new(project, nil, environment: @environment).mode @@ -217,8 +221,7 @@ def print_build_start(project, json:) details = [pipeline, "#{project.configuration.runtime} runtime", "#{cache} cache", "#{project.configuration.format.to_s.tr('_', '.')} output"] - @out.puts "Building #{project.app_name} with Dagger" - @out.puts " #{details.join(' · ')}" + progress.message("Building #{project.app_name} with Dagger\n #{details.join(' · ')}") end def dagger_session? @@ -226,9 +229,8 @@ def dagger_session? end def build_progress(configuration, json:) - enabled = !json && dagger_session? && configuration.progress.nil? - animated = enabled && @out.respond_to?(:tty?) && @out.tty? - BuildProgress.new(out: @out, enabled: enabled, animated: animated) + config = DaggerRuby::Config.new(progress: configuration.progress, verify_version: false) + config.progress_reporter(out: @out, enabled: !json && dagger_session?, environment: @environment) end end end diff --git a/lib/boringbuilder/exporter.rb b/lib/boringbuilder/exporter.rb index 5049b45..69fe62d 100644 --- a/lib/boringbuilder/exporter.rb +++ b/lib/boringbuilder/exporter.rb @@ -10,7 +10,7 @@ class Exporter attr_reader :project, :client, :container, :runtime, :exporters, :progress def initialize(project, client, container, runtime:, command_runner: nil, exporters: nil, - progress: BuildProgress.silent) + progress: DaggerRuby::Progress.silent) @project = project @client = client @container = container @@ -56,33 +56,36 @@ def merge_receipts(current, received) end def export_step_name(adapter) - return "Publish artifact to BoringCache" if adapter.name == :boringcache + return "[export] Publish artifact to BoringCache" if adapter.name == :boringcache - "Export #{configuration.format.to_s.tr('_', '.')} artifact" + "[export] Write #{configuration.format.to_s.tr('_', '.')} artifact" end def build_asset filename = project.output_path.basename.to_s case configuration.format when :directory - Exporters::Asset.new(kind: :directory, source: artifact_directory, filename: filename) + Exporters::Asset.new(kind: :directory, source: prepared_artifact_directory, filename: filename) when :tar Exporters::Asset.new(kind: :file, source: archive_file("artifact.tar", compression: nil), filename: filename) when :tar_zst Exporters::Asset.new(kind: :file, source: archive_file("artifact.tar.zst", compression: :zstd), filename: filename) when :oci, :docker - Exporters::Asset.new(kind: :file, source: container.as_tarball(media_types: media_types), filename: filename) + source = progress.step("[export] Create #{configuration.format.to_s.upcase} image archive") do + container.as_tarball(media_types: media_types).sync + end + Exporters::Asset.new(kind: :file, source: source, filename: filename) end end def export_reference if configuration.publish - progress.step("Publish container image") do + progress.step("[export] Publish container image") do container.publish(configuration.publish, media_types: media_types) end elsif configuration.load - progress.step("Load container image") { load_image } + progress.step("[export] Load container image") { load_image } end end @@ -123,20 +126,48 @@ def artifact_directory @artifact_directory ||= project.artifact.export_directory(client, container) end + def prepared_artifact_directory + @prepared_artifact_directory ||= progress.step("[export] Assemble artifact filesystem") do + artifact_directory.sync + end + end + def archive_file(filename, compression:) - packer = client.container.from(PACKER_IMAGE).with_exec(%w[apk add --no-cache tar zstd]) - packer = packer.with_mounted_directory("/artifact", artifact_directory) + packer = progress.step("[export] FROM #{PACKER_IMAGE}") { client.container.from(PACKER_IMAGE).sync } + packer = run_archive_step(packer, %w[apk add --no-cache tar zstd], "[export] RUN apk add tar zstd") + packer = packer.with_mounted_directory("/artifact", prepared_artifact_directory) tar_path = compression == :zstd ? "/artifact.tar" : "/#{filename}" - packer = packer.with_exec( + packer = run_archive_step( + packer, [ "tar", "--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "--pax-option=delete=atime,delete=ctime", "-C", "/artifact", "-cf", tar_path, "." - ] + ], + "[export] RUN tar -cf #{tar_path} ." ) - packer = packer.with_exec(["zstd", "-q", "-T1", tar_path, "-o", "/#{filename}"]) if compression == :zstd + if compression == :zstd + packer = run_archive_step( + packer, + ["zstd", "-q", "-T0", tar_path, "-o", "/#{filename}"], + "[export] RUN zstd -T0 #{tar_path}" + ) + end packer.file("/#{filename}") end + def run_archive_step(container, command, name) + executed = container.with_exec(command) + progress.step(name) do + output = command_output(executed) + progress.write(output) unless progress.streaming? + executed.sync + end + end + + def command_output(container) + [container.stdout, container.stderr].map(&:strip).reject(&:empty?).join("\n") + end + def media_types configuration.format == :docker ? :DockerMediaTypes : :OCIMediaTypes end diff --git a/lib/boringbuilder/mise.rb b/lib/boringbuilder/mise.rb index 17d0a30..781ec85 100644 --- a/lib/boringbuilder/mise.rb +++ b/lib/boringbuilder/mise.rb @@ -40,7 +40,7 @@ def install(container, tools:, workdir:, only: nil) at: CACHE_PATH, entry: "mise", workdir: workdir, - name: "Prepare Mise toolchain" + name: "[build] RUN #{command.join(' ')}" ) end diff --git a/lib/boringbuilder/pipeline.rb b/lib/boringbuilder/pipeline.rb index 288479f..ae804ca 100644 --- a/lib/boringbuilder/pipeline.rb +++ b/lib/boringbuilder/pipeline.rb @@ -4,7 +4,7 @@ module BoringBuilder class Pipeline attr_reader :project, :client, :progress - def initialize(project, client, environment: ENV, progress: BuildProgress.silent) + def initialize(project, client, environment: ENV, progress: DaggerRuby::Progress.silent) @project = project @client = client @environment = environment @@ -124,7 +124,10 @@ def sync_step(container, name, output: nil) return container if container.equal?(@last_synced_container) @last_synced_container = progress.step(name) do - progress.write { output.call } if output + if output + result = output.call + progress.write(result) unless progress.streaming? + end container.sync end end diff --git a/lib/boringbuilder/project.rb b/lib/boringbuilder/project.rb index 77d4f20..6bb885f 100644 --- a/lib/boringbuilder/project.rb +++ b/lib/boringbuilder/project.rb @@ -5,6 +5,7 @@ class Project DEFAULT_EXCLUDES = %w[ .env .env.* + .bundle/cache .git coverage dist @@ -29,7 +30,7 @@ def validate! self end - def container(client, progress: BuildProgress.silent) + def container(client, progress: DaggerRuby::Progress.silent) return Pipeline.new(self, client, progress: progress).build(&configuration.pipeline) if custom_pipeline? builder = rails? ? RailsBuild : RubyBuild diff --git a/lib/boringbuilder/ruby_build.rb b/lib/boringbuilder/ruby_build.rb index c605a76..cab7ddc 100644 --- a/lib/boringbuilder/ruby_build.rb +++ b/lib/boringbuilder/ruby_build.rb @@ -20,23 +20,29 @@ class RubyBuild "BUNDLE_CLEAN" => "true", "BUNDLE_DEPLOYMENT" => "1", "BUNDLE_IGNORE_CONFIG" => "true", - "BUNDLE_JOBS" => "1", "BUNDLE_PATH" => "/usr/local/bundle", "BUNDLE_WITHOUT" => "development:test" }.freeze - BUNDLE_INSTALL_COMMAND = ["sh", "-c", "bundle install && bundle clean --force"].freeze + BUNDLE_INSTALL_COMMAND = [ + "sh", "-c", + 'export BUNDLE_JOBS="${BUNDLE_JOBS:-$(nproc)}"; bundle install && bundle clean --force' + ].freeze + APT_INSTALL_COMMAND = [ + "sh", "-c", + "apt-get update && apt-get install -y --no-install-recommends \"$@\" && rm -rf /var/lib/apt/lists/*", + "apt-get" + ].freeze BUNDLE_FILES = %w[Gemfile Gemfile.lock .ruby-version].freeze BUNDLE_CONFIG = <<~YAML --- BUNDLE_PATH: "vendor/bundle" BUNDLE_WITHOUT: "development:test" - BUNDLE_JOBS: "1" BUNDLE_CLEAN: "true" YAML attr_reader :project, :client, :progress - def initialize(project, client, progress: BuildProgress.silent) + def initialize(project, client, progress: DaggerRuby::Progress.silent) @project = project @client = client @progress = progress @@ -44,15 +50,21 @@ def initialize(project, client, progress: BuildProgress.silent) def container builder = with_environment(build_image_container, build_environment) - builder = install_packages(builder, (BUILD_PACKAGES + configuration.build_packages).uniq) - builder = create_application_user(builder) + builder = install_packages(builder, (BUILD_PACKAGES + configuration.build_packages).uniq, stage: :build) + builder = create_application_user(builder, stage: :build) builder = install_toolchain(builder) builder = install_gems(builder) - builder = builder.with_directory(application_path, project.source(client), owner: user_owner) - builder = builder.with_workdir(application_path).with_exec(%w[bundle check]) + builder = pipeline.step("[build] COPY application source", builder) do |container| + container.with_directory(application_path, project.source(client), owner: user_owner) + end + builder = pipeline.exec( + builder, + %w[bundle check], + name: "[build] RUN bundle check", + workdir: application_path + ) builder = precompile(builder) - builder = write_bundle_config(builder) - builder = progress.step("Build #{project.framework.to_s.capitalize} application") { builder.sync } + builder = pipeline.step("[build] Write Bundler configuration", builder) { write_bundle_config(_1) } runtime_container(builder) end @@ -77,12 +89,12 @@ def image_options def build_image_container address = configuration.base_image || DEFAULT_BUILD_IMAGE - client.container(image_options).from(address) + progress.step("[build] FROM #{address}") { client.container(image_options).from(address).sync } end def runtime_image_container address = configuration.base_image || DEFAULT_RUNTIME_IMAGE - client.container(image_options).from(address) + progress.step("[runtime] FROM #{address}") { client.container(image_options).from(address).sync } end def build_environment @@ -119,7 +131,7 @@ def install_gems(container) cache: "bundle", at: "/usr/local/bundle/cache", entry: "bundler", - name: "Install gems" + name: "[build] RUN bundle install" ) container.with_exec(["find", "/usr/local/bundle", "-path", "*/cache/*.gem", "-type", "f", "-delete"]) .with_exec([ @@ -130,6 +142,7 @@ def install_gems(container) def write_bundle_config(container) container.with_workdir(application_path) + .with_exec(%w[rm -rf .bundle/cache]) .with_exec(%w[mkdir -p .bundle]) .with_new_file("#{application_path}/.bundle/config", BUNDLE_CONFIG) end @@ -140,41 +153,59 @@ def pipeline def runtime_container(builder) container = with_environment(runtime_image_container, Mise::ENVIRONMENT.merge(runtime_environment)) - container = install_packages(container, (RUNTIME_PACKAGES + configuration.runtime_packages).uniq) - container = create_application_user(container) - container = container.with_directory("/mise/installs", builder.directory("/mise/installs")) - .with_directory("/usr/local/bundle", builder.directory("/usr/local/bundle"), - owner: user_owner) - .with_directory(application_path, builder.directory(application_path), owner: user_owner) - .with_workdir(application_path) - .with_exec(%w[mise reshim]) + container = install_packages(container, (RUNTIME_PACKAGES + configuration.runtime_packages).uniq, + stage: :runtime) + container = create_application_user(container, stage: :runtime) + container = pipeline.step("[runtime] COPY toolchain, gems, and application", container) do |runtime| + runtime.with_directory("/mise/installs", builder.directory("/mise/installs")) + .with_directory("/usr/local/bundle", builder.directory("/usr/local/bundle"), owner: user_owner) + .with_directory(application_path, builder.directory(application_path), owner: user_owner) + end + container = pipeline.exec( + container, + %w[mise reshim], + name: "[runtime] RUN mise reshim", + workdir: application_path + ) container = runtime_metadata(container).with_user(project.application_user) - progress.step("Assemble runtime image") { container.sync } + progress.step("[runtime] Configure image") { container.sync } end - def install_packages(container, packages) - container - .with_exec(%w[apt-get update]) - .with_exec(["apt-get", "install", "-y", "--no-install-recommends", *packages]) - .with_exec(%w[rm -rf /var/lib/apt/lists]) + def install_packages(container, packages, stage:) + pipeline.exec( + container, + [*APT_INSTALL_COMMAND, *packages], + name: "[#{stage}] RUN apt-get update && apt-get install packages" + ) end - def create_application_user(container) + def create_application_user(container, stage:) name = project.application_user - container - .with_exec(["groupadd", "--system", "--gid", "1000", name]) - .with_exec(["useradd", name, "--uid", "1000", "--gid", "1000", "--create-home", "--shell", "/bin/bash"]) + pipeline.step("[#{stage}] Create application user", container) do |step| + step.with_exec(["groupadd", "--system", "--gid", "1000", name]) + .with_exec(["useradd", name, "--uid", "1000", "--gid", "1000", "--create-home", "--shell", "/bin/bash"]) + end end def precompile(container) - container = container.with_exec(%w[bin/rails assets:precompile]) if project.rails? && project.assets? + if project.rails? && project.assets? + container = pipeline.exec( + container, + %w[bin/rails assets:precompile], + name: "[build] RUN bin/rails assets:precompile" + ) + end if project.bootsnap? paths = project.rails? ? %w[app/ lib/] : %w[app/ lib/ config/] - container = container.with_exec(["bundle", "exec", "bootsnap", "precompile", "--gemfile", *paths]) + container = pipeline.exec( + container, + ["bundle", "exec", "bootsnap", "precompile", "--gemfile", *paths], + name: "[build] RUN bundle exec bootsnap precompile" + ) end return container unless project.rails? - normalize_rails_output(container) + pipeline.step("[build] Normalize Rails output", container) { normalize_rails_output(_1) } end def normalize_rails_output(container) diff --git a/lib/boringbuilder/version.rb b/lib/boringbuilder/version.rb index ad07921..e854b6a 100644 --- a/lib/boringbuilder/version.rb +++ b/lib/boringbuilder/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module BoringBuilder - VERSION = "0.1.0.alpha.1" + VERSION = "0.1.0.alpha.2" end diff --git a/test/boringbuilder/build_progress_test.rb b/test/boringbuilder/build_progress_test.rb deleted file mode 100644 index 557f8fd..0000000 --- a/test/boringbuilder/build_progress_test.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class BuildProgressTest < Minitest::Test - def test_prints_numbered_steps_and_returns_the_result - output = StringIO.new - progress = BoringBuilder::BuildProgress.new(out: output) - - result = progress.step("Install dependencies") { :finished } - - assert_equal :finished, result - assert_includes output.string, "#1 Install dependencies\n" - assert_match(/#1 DONE \d+\.\d+s\n/, output.string) - end - - def test_numbers_steps_in_execution_order - output = StringIO.new - progress = BoringBuilder::BuildProgress.new(out: output) - - progress.step("Prepare Mise toolchain") { true } - progress.step("Build application") { true } - - assert_operator output.string.index("#1 Prepare Mise toolchain"), :<, output.string.index("#2 Build application") - end - - def test_marks_a_failed_step_and_preserves_the_error - output = StringIO.new - progress = BoringBuilder::BuildProgress.new(out: output) - - error = assert_raises(RuntimeError) do - progress.step("Build application") { raise "broken build" } - end - - assert_equal "broken build", error.message - assert_match(/#1 ERROR \d+\.\d+s\n/, output.string) - end - - def test_indents_command_output - output = StringIO.new - progress = BoringBuilder::BuildProgress.new(out: output) - - progress.step("Build application") { progress.write("compiled\nready") } - - assert_includes output.string, " compiled\n ready\n" - end - - def test_silent_progress_executes_without_output - output = StringIO.new - progress = BoringBuilder::BuildProgress.new(out: output, enabled: false) - - result = progress.step("Build application") { :finished } - - assert_equal :finished, result - assert_empty output.string - end -end diff --git a/test/boringbuilder/exporter_test.rb b/test/boringbuilder/exporter_test.rb index 17719b2..34de83c 100644 --- a/test/boringbuilder/exporter_test.rb +++ b/test/boringbuilder/exporter_test.rb @@ -32,10 +32,32 @@ def test_exports_selected_paths_as_a_zstandard_tarball "--pax-option=delete=atime,delete=ctime", "-C", "/artifact", "-cf", "/artifact.tar", "."]], {}] zstd_call = client.calls.find do |name, arguments, _options| - name == :with_exec && arguments.flatten.include?("zstd") + name == :with_exec && arguments.first.first == "zstd" end refute_nil zstd_call + assert_includes zstd_call[1].first, "-T0" + end + + def test_describes_the_real_archive_steps + root = build_project + configuration = BoringBuilder::Configuration.new(root: root, format: :tar_zst) + project = BoringBuilder::Project.new(configuration).validate! + client = RecordingClient.new + progress_output = StringIO.new + + BoringBuilder::Exporter.new( + project, + client, + RecordingNode.new(client.calls), + runtime: :docker, + progress: DaggerRuby::Progress.new(out: progress_output) + ).call + + assert_includes progress_output.string, "[export] Assemble artifact filesystem" + assert_includes progress_output.string, "[export] RUN tar -cf /artifact.tar ." + assert_includes progress_output.string, "[export] RUN zstd -T0 /artifact.tar" + assert_includes progress_output.string, "[export] Write tar.zst artifact" end def test_assembles_remapped_container_and_host_content diff --git a/test/boringbuilder/pipeline_test.rb b/test/boringbuilder/pipeline_test.rb index 5c52e79..40a5461 100644 --- a/test/boringbuilder/pipeline_test.rb +++ b/test/boringbuilder/pipeline_test.rb @@ -75,7 +75,7 @@ def test_executes_a_named_step_and_prints_its_real_output project = BoringBuilder::Project.new(BoringBuilder::Configuration.new(root: root)).validate! client = RecordingClient.new(stdout: "compiled\n", stderr: "one warning\n") output = StringIO.new - progress = BoringBuilder::BuildProgress.new(out: output) + progress = DaggerRuby::Progress.new(out: output) pipeline = BoringBuilder::Pipeline.new(project, client, environment: {}, progress: progress) pipeline.exec(client.container, %w[rake compile], name: "Build application") diff --git a/test/boringbuilder/project_test.rb b/test/boringbuilder/project_test.rb index 130fdf3..6c1bc0b 100644 --- a/test/boringbuilder/project_test.rb +++ b/test/boringbuilder/project_test.rb @@ -40,6 +40,7 @@ def test_keeps_environment_files_out_of_the_build_context assert_includes BoringBuilder::Project::DEFAULT_EXCLUDES, ".env" assert_includes BoringBuilder::Project::DEFAULT_EXCLUDES, ".env.*" + assert_includes BoringBuilder::Project::DEFAULT_EXCLUDES, ".bundle/cache" assert_includes client.calls, [:directory, [root.to_s], { exclude: BoringBuilder::Project::DEFAULT_EXCLUDES, gitignore: true }] end diff --git a/test/boringbuilder/rails_build_test.rb b/test/boringbuilder/rails_build_test.rb index bbee93d..5d2824a 100644 --- a/test/boringbuilder/rails_build_test.rb +++ b/test/boringbuilder/rails_build_test.rb @@ -33,17 +33,10 @@ def test_keeps_build_packages_out_of_the_runtime_stage BoringBuilder::RailsBuild.new(project, client).container - owned_directories = client.calls.filter_map do |method, arguments, options| - arguments.first if method == :with_directory && options[:owner] == "rails:rails" - end - assert_equal 2, client.calls.count([:container, [{}], {}]) - assert_includes client.calls, - [:with_exec, [["apt-get", "install", "-y", "--no-install-recommends", - "build-essential", "curl", "git", "libpq-dev", "libyaml-dev", "pkg-config"]], {}] - assert_includes client.calls, - [:with_exec, [["apt-get", "install", "-y", "--no-install-recommends", "curl", "libpq5"]], {}] - assert_equal ["/rails", "/rails", "/usr/local/bundle"], owned_directories.sort + assert_includes package_commands(client), %w[build-essential curl git libpq-dev libyaml-dev pkg-config] + assert_includes package_commands(client), %w[curl libpq5] + assert_equal ["/rails", "/rails", "/usr/local/bundle"], owned_directories(client).sort end def test_uses_rails_docker_entrypoint_when_present @@ -78,4 +71,20 @@ def test_uses_thruster_when_present method == :with_docker_healthcheck && arguments.first.any? { |argument| argument.include?("localhost:80/up") } end) end + + private + + def package_commands(client) + client.calls + .select { |call| call.first == :with_exec } + .map { |call| call[1].first } + .select { |command| command.first(4) == BoringBuilder::RubyBuild::APT_INSTALL_COMMAND } + .map { |command| command.drop(4) } + end + + def owned_directories(client) + client.calls.filter_map do |method, arguments, options| + arguments.first if method == :with_directory && options[:owner] == "rails:rails" + end + end end diff --git a/test/boringbuilder/ruby_build_test.rb b/test/boringbuilder/ruby_build_test.rb index c92156c..ec590ec 100644 --- a/test/boringbuilder/ruby_build_test.rb +++ b/test/boringbuilder/ruby_build_test.rb @@ -26,6 +26,23 @@ def test_installs_ruby_and_gems_with_persisted_caches assert_includes config[1].fetch(1), '"ruby" = "3.4.9"' end + def test_uses_the_build_container_cpu_count_for_bundler_by_default + refute BoringBuilder::RubyBuild::BUNDLE_ENVIRONMENT.key?("BUNDLE_JOBS") + assert_includes BoringBuilder::RubyBuild::BUNDLE_INSTALL_COMMAND.last, "${BUNDLE_JOBS:-$(nproc)}" + refute_includes BoringBuilder::RubyBuild::BUNDLE_CONFIG, "BUNDLE_JOBS" + end + + def test_removes_bundlers_compact_index_from_the_application + root = rack_project + configuration = BoringBuilder::Configuration.new(root: root) + project = BoringBuilder::Project.new(configuration).validate! + client = RecordingClient.new + + BoringBuilder::RubyBuild.new(project, client).container + + assert_includes client.calls, [:with_exec, [%w[rm -rf .bundle/cache]], {}] + end + def test_installs_only_ruby_from_a_project_mise_file root = rack_project File.write(root.join("mise.toml"), <<~TOML)