Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions bin/ci
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions bin/release-notes
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/boringbuilder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions lib/boringbuilder/build_receipt.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion lib/boringbuilder/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
18 changes: 18 additions & 0 deletions lib/boringbuilder/release_notes.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions lib/boringbuilder/ruby_build.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:)
Expand Down
2 changes: 1 addition & 1 deletion lib/boringbuilder/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module BoringBuilder
VERSION = "0.1.0.alpha.4"
VERSION = "0.1.0.alpha.5"
end
47 changes: 47 additions & 0 deletions test/boringbuilder/build_receipt_test.rb
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions test/boringbuilder/release_notes_test.rb
Original file line number Diff line number Diff line change
@@ -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