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
8 changes: 7 additions & 1 deletion .github/release-rules/checks.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@
"context": "RuboCop"
},
{
"context": "Documentation coverage"
"context": "ruby on ubuntu"
},
{
"context": "ruby on macos"
},
{
"context": "validate"
},
{
"context": "Release validation"
Expand Down
22 changes: 12 additions & 10 deletions .github/workflows/documentation-coverage.yaml
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
name: Documentation coverage
name: Documentation Coverage

on: [push, pull_request]

permissions:
contents: read

env:
COVERAGE: PartialSummary
BUNDLE_WITH: maintenance

jobs:
validate:
name: Documentation coverage
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1
with:
ruby-version: "3.4"
bundler-cache: true
- name: Check library documentation
timeout-minutes: 5
run: bundle exec bake decode:index:coverage lib
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: ruby
bundler-cache: true

- name: Validate coverage
timeout-minutes: 5
run: bundle exec bake decode:index:coverage lib
58 changes: 58 additions & 0 deletions .github/workflows/test-coverage.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Test Coverage

on: [push, pull_request]

permissions:
contents: read

env:
COVERAGE: PartialSummary

jobs:
test:
name: ${{matrix.ruby}} on ${{matrix.os}}
runs-on: ${{matrix.os}}-latest

strategy:
matrix:
os:
- ubuntu
- macos

ruby:
- ruby

steps:
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{matrix.ruby}}
bundler-cache: true

- name: Run tests
timeout-minutes: 5
run: bundle exec bake test

- uses: actions/upload-artifact@v7
with:
include-hidden-files: true
if-no-files-found: error
name: coverage-${{matrix.os}}-${{matrix.ruby}}
path: .covered.db

validate:
needs: test
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: ruby
bundler-cache: true

- uses: actions/download-artifact@v8

- name: Validate coverage
timeout-minutes: 5
run: bundle exec bake covered:validate --paths */.covered.db \;
4 changes: 3 additions & 1 deletion config/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ checks:
- Test Ruby 3.4
- Test Ruby 4.0
- RuboCop
- Documentation coverage
- ruby on ubuntu
- ruby on macos
- validate
- Release validation
approvals: 2
signing: true
Expand Down
48 changes: 48 additions & 0 deletions fixtures/bake/gem/github/project_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "bake/gem/github/project"
require "bake/context"

module Bake
module Gem
module GitHub
# Substitute GitHub transport while retaining real Git and Bake operations.
class ProjectClient < Project
attr_accessor :pulls, :responses
attr_reader :requests, :writes

def initialize(root)
super
@pulls = []
@responses = {}
@requests = []
@writes = []
end

def readlines(*arguments, **options)
return super unless arguments.first == "gh"
@requests << arguments
case arguments[1, 2]
when ["pr", "list"]
[JSON.generate(@pulls)]
when ["pr", "create"]
@writes << File.read(arguments[arguments.index("--body-file") + 1])
["https://github.com/socketry/example/pull/42\n"]
else
[JSON.generate(@responses.fetch(arguments.fetch(2)))]
end
end

def system(*arguments, **options)
return super unless arguments.first == "gh"
@requests << arguments
@writes << JSON.parse(File.read(arguments[arguments.index("--input") + 1]))
true
end
end
end
end
end
70 changes: 70 additions & 0 deletions fixtures/bake/gem/github/repository_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "sus/shared"
require "sus/fixtures/temporary_directory_context"
require "sus/fixtures/isolated_ruby_context"
require "bake/gem/github/setup"

module Bake
module Gem
module GitHub
RepositoryContext = Sus::Shared("a gem repository") do
include Sus::Fixtures::TemporaryDirectoryContext
include Sus::Fixtures::IsolatedRubyContext

def repository
File.join(root, "source")
end

def git(*arguments, chdir: repository)
output = IO.popen(["git", *arguments], chdir: chdir, &:read)
raise "git failed: #{arguments.inspect}" unless $?.success?
output.strip
end

before do
FileUtils.mkdir_p(File.join(repository, "lib/example"))
File.write(File.join(repository, "lib/example/version.rb"), "module Example; VERSION = \"1.0.0\"; end\n")
File.write(File.join(repository, "example.gemspec"), <<~RUBY)
require_relative "lib/example/version"
Gem::Specification.new do |spec|
spec.name = "example"
spec.version = Example::VERSION
spec.summary = "Example consumer"
spec.authors = ["Test"]
spec.license = "MIT"
spec.homepage = "https://example.com"
spec.required_ruby_version = ">= 3.3"
spec.files = Dir.glob(["lib/**/*.rb", "releases.md"])
end
RUBY
File.write(File.join(repository, "releases.md"), "## Unreleased\n\nA change.\n")
File.write(File.join(repository, "bake.rb"), <<~'RUBY')
def after_gem_release_version_increment(version)
File.write("releases.md", File.read("releases.md").sub("Unreleased", version.to_s))
end
RUBY
File.write(File.join(repository, ".gitignore"), "/pkg/\n")
Setup.new(repository).generate(repository: "socketry/example", checks: ["Tests"], signing: false)
git("init", "--quiet", "--initial-branch=main")
git("config", "core.hooksPath", File::NULL)
git("config", "commit.gpgsign", "false")
git("config", "user.name", "Test")
git("config", "user.email", "test@example.com")
git("add", "--all")
git("commit", "--quiet", "-m", "Initial source")
git("init", "--quiet", "--bare", "--initial-branch=main", File.join(root, "remote"))
git("remote", "add", "origin", File.join(root, "remote"))
git("push", "--quiet", "--set-upstream", "origin", "main")
end

def isolated_project(source, env: {})
isolated_ruby(source, chdir: repository, env: env, requires: ["bundler/setup", File.expand_path("project_client.rb", __dir__)])
end
end
end
end
end
1 change: 1 addition & 0 deletions gems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
gemspec

group :test do
gem "bake-test"
gem "covered"
gem "sus"
gem "rubocop"
Expand Down
140 changes: 140 additions & 0 deletions test/bake/gem/github/project.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "bake/gem/github/repository_context"
require "bake/gem/github/project_client"

describe Bake::Gem::GitHub::Project do
include Bake::Gem::GitHub::RepositoryContext

with "#prepare" do
it "prepares, validates, and pushes a release before opening its PR" do
result = isolated_project(<<~'RUBY')
project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd)
url = project.prepare(Bake::Context.load(Dir.pwd), "patch")
{url: url, body: project.writes.first}
RUBY
expect(result[:url]).to be == "https://github.com/socketry/example/pull/42"
expect(result[:body]).to be(:include?, "Release example 1.0.1.")
expect(git("branch", "--show-current")).to be == "releases/v1.0.1"
expect(git("rev-parse", "HEAD")).to be == git("rev-parse", "refs/heads/releases/v1.0.1", chdir: File.join(root, "remote"))
expect(File.read(File.join(repository, "releases.md"))).to be(:include?, "## v1.0.1")
expect(git("tag", "--list")).to be == ""
git("checkout", "--quiet", "main")
git("merge", "--quiet", "--no-ff", "releases/v1.0.1", "-m", "Merge release")
git("push", "--quiet", "origin", "main")
commit = git("rev-parse", "HEAD")
project = Bake::Gem::GitHub::ProjectClient.new(repository)
project.responses["repos/socketry/example/pulls/42"] = {
"number" => 42, "merged" => true, "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}},
"merge_commit_sha" => commit, "merged_by" => {"login" => "maintainer"}, "html_url" => result[:url]
}
evidence = project.inspect_release(42)
expect(evidence).to have_keys(name: be == "example", version: be == "1.0.1", commit: be == commit, merged_by: be == "maintainer")
end

it "reports an existing release PR without creating a branch" do
url = isolated_project(<<~'RUBY')
project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd)
project.pulls = [{"headRefName" => "releases/v1.0.1", "url" => "existing"}]
project.prepare(Bake::Context.load(Dir.pwd), "patch")
RUBY
expect(url).to be == "existing"
expect(git("branch", "--show-current")).to be == "main"
end

it "refuses preparation from another branch" do
git("checkout", "--quiet", "-b", "feature")
expect do
isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")')
end.to raise_exception(RuntimeError, message: be =~ /Prepare releases from main/)
end

it "refuses preparation when the local default branch differs from origin" do
git("commit", "--quiet", "--allow-empty", "-m", "Local change")
expect do
isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")')
end.to raise_exception(RuntimeError, message: be =~ /differs from origin/)
end
end

with "#merged and #inspect_release" do
let(:project) {Bake::Gem::GitHub::ProjectClient.new(repository)}
let(:pull) {{"merged" => true, "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}}, "merge_commit_sha" => git("rev-parse", "HEAD")}}

before do
project.responses["repos/socketry/example/pulls/42"] = pull
end

it "accepts a merged commit in the remote default branch history" do
git("commit", "--quiet", "--allow-empty", "-m", "Later development")
git("push", "--quiet", "origin", "main")
expect(project.merged(42)).to be == pull
end

it "rejects a commit outside the remote default branch history" do
git("checkout", "--quiet", "-b", "unmerged")
git("commit", "--quiet", "--allow-empty", "-m", "Unmerged change")
pull["merge_commit_sha"] = git("rev-parse", "HEAD")
expect{project.merged(42)}.to raise_exception(Bake::Gem::CommandExecutionError)
end

it "rejects invalid commit identifiers" do
pull["merge_commit_sha"] = "--help"
expect{project.merged(42)}.to raise_exception(RuntimeError, message: be =~ /Invalid merged commit/)
end

it "does not publish ordinary merged changes" do
File.write(File.join(repository, "readme.md"), "An ordinary change.\n")
git("add", "readme.md")
git("commit", "--quiet", "-m", "Documentation")
git("push", "--quiet", "origin", "main")
pull["merge_commit_sha"] = git("rev-parse", "HEAD")
expect(project.inspect_release(42)).to be_nil
end
end

with "#doctor and #apply" do
let(:project) {Bake::Gem::GitHub::ProjectClient.new(repository)}
let(:rules) {[]}

before do
project.responses["repos/socketry/example/rulesets?per_page=100"] = rules
project.responses["repos/socketry/example/environments"] = {"environments" => []}
end

it "reports desired and observed settings without writing" do
expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project)
result = Bake::Context.load(repository).call("gem:github:setup:plan")
expect(result[:desired_rules].keys).to be == %w[reviews checks history tags]
expect(result[:existing_rules]).to be == []
expect(result[:trusted_publisher][:workflow_filename]).to be == "release-publish.yaml"
expect(project.writes).to be == []
end

it "creates missing managed rulesets while preserving unrelated rulesets" do
rules << {"id" => 123, "name" => "Unrelated policy"}
expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project)
Bake::Context.load(repository).call("gem:github:setup:apply")
expect(project.writes.map{|rule| rule.fetch("name")}).to be == ["Gem release reviews", "Gem release checks", "Gem release history", "Gem release tags"]
expect(project.requests.drop(1).all?{|request| request.include?("POST")}).to be == true
end

it "updates existing managed rulesets by ID" do
Bake::Gem::GitHub::Setup.rules(project.config).values.each_with_index do |rule, index|
rules << {"id" => index + 1, "name" => rule.fetch(:name)}
end
project.apply
expect(project.requests.drop(1).map{|request| request[2]}).to be == (1..4).map{|id| "repos/socketry/example/rulesets/#{id}"}
expect(project.requests.drop(1).all?{|request| request.include?("PUT")}).to be == true
end

it "refuses ambiguous managed rulesets" do
rules.concat([{"id" => 1, "name" => "Gem release reviews"}, {"id" => 2, "name" => "Gem release reviews"}])
expect{project.apply}.to raise_exception(RuntimeError, message: be =~ /Multiple rulesets/)
expect(project.writes).to be == []
end
end
end
Loading
Loading