From 414913df199106aa376845fdd4f3644bb1317449 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Fri, 28 Aug 2026 15:46:39 +0100 Subject: [PATCH] Connect builds to deployable artifacts --- .github/workflows/publish.yml | 14 +++++++ CHANGELOG.md | 6 +++ README.md | 3 +- bin/ci | 1 + bin/release-notes | 14 +++++++ lib/boringbuilder.rb | 1 + lib/boringbuilder/build_receipt.rb | 47 ++++++++++++++++++++++++ lib/boringbuilder/builder.rb | 4 +- lib/boringbuilder/release_notes.rb | 18 +++++++++ lib/boringbuilder/ruby_build.rb | 5 ++- lib/boringbuilder/version.rb | 2 +- test/boringbuilder/build_receipt_test.rb | 47 ++++++++++++++++++++++++ test/boringbuilder/release_notes_test.rb | 36 ++++++++++++++++++ 13 files changed, 193 insertions(+), 5 deletions(-) create mode 100755 bin/release-notes create mode 100644 lib/boringbuilder/build_receipt.rb create mode 100644 lib/boringbuilder/release_notes.rb create mode 100644 test/boringbuilder/build_receipt_test.rb create mode 100644 test/boringbuilder/release_notes_test.rb diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aa41dad..3197448 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,5 +26,19 @@ jobs: bundler-cache: true - name: Verify release run: bin/ci + - name: Prepare release notes + id: release + run: | + version="$(bundle exec ruby -Ilib -rboringbuilder/version -e 'print BoringBuilder::VERSION')" + bin/release-notes > "$RUNNER_TEMP/release-notes.md" + echo "version=$version" >> "$GITHUB_OUTPUT" - name: Publish with RubyGems trusted publishing uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1 + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "v${{ steps.release.outputs.version }}" + --verify-tag + --title "BoringBuilder ${{ steps.release.outputs.version }}" + --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5980da4..2eb96e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.0.alpha.5 - 2026-08-28 + +- Record the exact local or BoringCache build result for deployment tools. +- Finalize Rails runtime metadata without starting the application command. +- Publish each release with the matching human changelog notes. + ## 0.1.0.alpha.4 - 2026-08-28 - Expose the Rails build task as `boring:build`. diff --git a/README.md b/README.md index 7ca2324..8bb96fb 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ See [Caching](docs/caching.md) for custom cached steps and restore-only builds. The default `tar.zst` output is intended for atomic filesystem deployers such as BoringDeploy. Without shared credentials it is written under `dist/`. With BoringCache save credentials it is published as a shared BoringCache -Artifact instead; pass `--output` when a local copy is also wanted. +Artifact instead; pass `--output` when a local copy is also wanted. Each successful export records its exact result in +`tmp/builds/boringbuilder.json` so deployment tools can consume the same artifact without guessing. The same build can produce or publish a container image: diff --git a/bin/ci b/bin/ci index f99c982..79a3326 100755 --- a/bin/ci +++ b/bin/ci @@ -4,6 +4,7 @@ set -euo pipefail cd "$(dirname "$0")/.." bundle check +bundle exec ruby bin/release-notes > /dev/null bundle exec rake bundle exec bundle-audit check --update bundle exec rake build diff --git a/bin/release-notes b/bin/release-notes new file mode 100755 index 0000000..6025121 --- /dev/null +++ b/bin/release-notes @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/boringbuilder/release_notes" +require_relative "../lib/boringbuilder/version" + +begin + version = ARGV.fetch(0, BoringBuilder::VERSION) + path = ARGV.fetch(1, File.expand_path("../CHANGELOG.md", __dir__)) + puts BoringBuilder::ReleaseNotes.read(path: path, version: version) +rescue ArgumentError => e + warn e.message + exit 1 +end diff --git a/lib/boringbuilder.rb b/lib/boringbuilder.rb index cf9f65a..2018454 100644 --- a/lib/boringbuilder.rb +++ b/lib/boringbuilder.rb @@ -20,6 +20,7 @@ require_relative "boringbuilder/rails_build" require_relative "boringbuilder/exporter" require_relative "boringbuilder/result" +require_relative "boringbuilder/build_receipt" require_relative "boringbuilder/builder" require_relative "boringbuilder/cli" require_relative "boringbuilder/railtie" if defined?(Rails::Railtie) diff --git a/lib/boringbuilder/build_receipt.rb b/lib/boringbuilder/build_receipt.rb new file mode 100644 index 0000000..790c0f4 --- /dev/null +++ b/lib/boringbuilder/build_receipt.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" +require "open3" +require "pathname" +require "tempfile" +require "time" + +module BoringBuilder + class BuildReceipt + RELATIVE_PATH = "tmp/builds/boringbuilder.json" + + def self.write(root:, result:, clock: Time, command_runner: Open3.method(:capture3)) + return unless result.exported? || result.artifact_published? || result.published? + + root = Pathname.new(root).expand_path + path = root.join(RELATIVE_PATH) + FileUtils.mkdir_p(path.dirname) + document = { + schema_version: 1, + created_at: clock.now.utc.iso8601, + source_revision: source_revision(root, command_runner), + result: result.to_h + } + + Tempfile.create(["boringbuilder", ".json"], path.dirname) do |file| + file.write(JSON.pretty_generate(document)) + file.write("\n") + file.flush + file.fsync + File.rename(file.path, path) + end + + path + end + + def self.source_revision(root, command_runner) + stdout, _stderr, status = command_runner.call("git", "-C", root.to_s, "rev-parse", "HEAD") + status.success? ? stdout.strip : nil + rescue Errno::ENOENT + nil + end + + private_class_method :source_revision + end +end diff --git a/lib/boringbuilder/builder.rb b/lib/boringbuilder/builder.rb index 2490e69..9b689cf 100644 --- a/lib/boringbuilder/builder.rb +++ b/lib/boringbuilder/builder.rb @@ -21,10 +21,12 @@ def build silent: configuration.progress.nil? ) - DaggerRuby.connection(dagger_configuration) do |client| + result = DaggerRuby.connection(dagger_configuration) do |client| container = project.container(client, progress: progress) Exporter.new(project, client, container, runtime: resolved_runtime, progress: progress).call end + BuildReceipt.write(root: configuration.root, result: result) + result rescue DaggerRuby::DaggerError => e message = e.message.sub(/\s*\[traceparent:[^\]]+\]\s*\z/, "") raise BuildError, "Dagger build failed: #{message}" diff --git a/lib/boringbuilder/release_notes.rb b/lib/boringbuilder/release_notes.rb new file mode 100644 index 0000000..8aecf3f --- /dev/null +++ b/lib/boringbuilder/release_notes.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module BoringBuilder + class ReleaseNotes + def self.read(path:, version:) + sections = File.read(path).lines + heading = /^## #{Regexp.escape(version)} - \d{4}-\d{2}-\d{2}\s*$/ + start = sections.index { |line| line.match?(heading) } + raise ArgumentError, "CHANGELOG.md has no release notes for #{version}" unless start + + finish = sections.each_index.find { |index| index > start && sections[index].start_with?("## ") } + notes = sections[(start + 1)...finish].join.strip + raise ArgumentError, "CHANGELOG.md release notes for #{version} are empty" if notes.empty? + + notes + end + end +end diff --git a/lib/boringbuilder/ruby_build.rb b/lib/boringbuilder/ruby_build.rb index 3255896..3090c88 100644 --- a/lib/boringbuilder/ruby_build.rb +++ b/lib/boringbuilder/ruby_build.rb @@ -173,8 +173,9 @@ def runtime_container(builder) name: "[runtime] RUN mise reshim", workdir: application_path ) - container = runtime_metadata(container).with_user(project.application_user) - progress.step("[runtime] Configure image") { container.sync } + container = container.with_user(project.application_user) + container = progress.step("[runtime] Configure image") { container.sync } + runtime_metadata(container) end def install_packages(container, packages, stage:) diff --git a/lib/boringbuilder/version.rb b/lib/boringbuilder/version.rb index d21d21b..640af62 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.4" + VERSION = "0.1.0.alpha.5" end diff --git a/test/boringbuilder/build_receipt_test.rb b/test/boringbuilder/build_receipt_test.rb new file mode 100644 index 0000000..3e39b12 --- /dev/null +++ b/test/boringbuilder/build_receipt_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" + +class BuildReceiptTest < Minitest::Test + include ProjectFixture + + def test_records_the_exact_build_result_and_source_revision + root = build_project + result = BoringBuilder::Result.new( + format: :tar_zst, + path: nil, + reference: nil, + runtime: :apple, + platform: "linux/arm64", + artifact_id: "art_0123456789abcdef01234567", + artifact_name: "example-native-tar-zst" + ) + status = Struct.new(:success?).new(true) + runner = ->(*_command) { ["abc123\n", "", status] } + clock = Struct.new(:now).new(Time.utc(2026, 8, 28, 15, 30)) + + path = BoringBuilder::BuildReceipt.write(root: root, result: result, clock: clock, command_runner: runner) + receipt = JSON.parse(path.read) + + assert_equal root.join("tmp/builds/boringbuilder.json"), path + assert_equal 1, receipt.fetch("schema_version") + assert_equal "2026-08-28T15:30:00Z", receipt.fetch("created_at") + assert_equal "abc123", receipt.fetch("source_revision") + assert_equal "art_0123456789abcdef01234567", receipt.dig("result", "artifact_id") + assert_equal "example-native-tar-zst", receipt.dig("result", "artifact_name") + end + + def test_does_not_write_a_receipt_when_nothing_was_exported + root = build_project + result = BoringBuilder::Result.new( + format: :tar_zst, + path: nil, + reference: nil, + runtime: :docker, + platform: "linux/amd64" + ) + + assert_nil BoringBuilder::BuildReceipt.write(root: root, result: result) + refute_predicate root.join("tmp/builds/boringbuilder.json"), :exist? + end +end diff --git a/test/boringbuilder/release_notes_test.rb b/test/boringbuilder/release_notes_test.rb new file mode 100644 index 0000000..0c4c9a7 --- /dev/null +++ b/test/boringbuilder/release_notes_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "test_helper" +require "boringbuilder/release_notes" + +class ReleaseNotesTest < Minitest::Test + def test_reads_one_human_changelog_section + changelog = Tempfile.new("changelog") + changelog.write(<<~MARKDOWN) + # Changelog + + ## 1.2.3 - 2026-08-28 + + - Build receipts connect builds to deploys. + + ## 1.2.2 - 2026-08-27 + + - Earlier work. + MARKDOWN + changelog.close + + notes = BoringBuilder::ReleaseNotes.read(path: changelog.path, version: "1.2.3") + + assert_equal "- Build receipts connect builds to deploys.", notes + ensure + changelog&.unlink + end + + def test_rejects_a_missing_release + error = assert_raises(ArgumentError) do + BoringBuilder::ReleaseNotes.read(path: "CHANGELOG.md", version: "9.9.9") + end + + assert_equal "CHANGELOG.md has no release notes for 9.9.9", error.message + end +end