Skip to content

Commit 64ca411

Browse files
Exercise release preparation and repository policy. (#12)
1 parent 4658aa3 commit 64ca411

16 files changed

Lines changed: 663 additions & 141 deletions

File tree

‎.github/release-rules/checks.json‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@
3737
"context": "RuboCop"
3838
},
3939
{
40-
"context": "Documentation coverage"
40+
"context": "ruby on ubuntu"
41+
},
42+
{
43+
"context": "ruby on macos"
44+
},
45+
{
46+
"context": "validate"
4147
},
4248
{
4349
"context": "Release validation"
Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
1-
name: Documentation coverage
1+
name: Documentation Coverage
22

33
on: [push, pull_request]
44

55
permissions:
66
contents: read
77

88
env:
9+
COVERAGE: PartialSummary
910
BUNDLE_WITH: maintenance
1011

1112
jobs:
1213
validate:
13-
name: Documentation coverage
1414
runs-on: ubuntu-latest
15+
1516
steps:
16-
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
17-
- uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1
18-
with:
19-
ruby-version: "3.4"
20-
bundler-cache: true
21-
- name: Check library documentation
22-
timeout-minutes: 5
23-
run: bundle exec bake decode:index:coverage lib
17+
- uses: actions/checkout@v7
18+
- uses: ruby/setup-ruby@v1
19+
with:
20+
ruby-version: ruby
21+
bundler-cache: true
22+
23+
- name: Validate coverage
24+
timeout-minutes: 5
25+
run: bundle exec bake decode:index:coverage lib
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Test Coverage
2+
3+
on: [push, pull_request]
4+
5+
permissions:
6+
contents: read
7+
8+
env:
9+
COVERAGE: PartialSummary
10+
11+
jobs:
12+
test:
13+
name: ${{matrix.ruby}} on ${{matrix.os}}
14+
runs-on: ${{matrix.os}}-latest
15+
16+
strategy:
17+
matrix:
18+
os:
19+
- ubuntu
20+
- macos
21+
22+
ruby:
23+
- ruby
24+
25+
steps:
26+
- uses: actions/checkout@v7
27+
- uses: ruby/setup-ruby@v1
28+
with:
29+
ruby-version: ${{matrix.ruby}}
30+
bundler-cache: true
31+
32+
- name: Run tests
33+
timeout-minutes: 5
34+
run: bundle exec bake test
35+
36+
- uses: actions/upload-artifact@v7
37+
with:
38+
include-hidden-files: true
39+
if-no-files-found: error
40+
name: coverage-${{matrix.os}}-${{matrix.ruby}}
41+
path: .covered.db
42+
43+
validate:
44+
needs: test
45+
runs-on: ubuntu-latest
46+
47+
steps:
48+
- uses: actions/checkout@v7
49+
- uses: ruby/setup-ruby@v1
50+
with:
51+
ruby-version: ruby
52+
bundler-cache: true
53+
54+
- uses: actions/download-artifact@v8
55+
56+
- name: Validate coverage
57+
timeout-minutes: 5
58+
run: bundle exec bake covered:validate --paths */.covered.db \;

‎config/release.yaml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ checks:
77
- Test Ruby 3.4
88
- Test Ruby 4.0
99
- RuboCop
10-
- Documentation coverage
10+
- ruby on ubuntu
11+
- ruby on macos
12+
- validate
1113
- Release validation
1214
approvals: 2
1315
signing: true
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "bake/gem/github/project"
7+
require "bake/context"
8+
9+
module Bake
10+
module Gem
11+
module GitHub
12+
# Substitute GitHub transport while retaining real Git and Bake operations.
13+
class ProjectClient < Project
14+
attr_accessor :pulls, :responses
15+
attr_reader :requests, :writes
16+
17+
def initialize(root)
18+
super
19+
@pulls = []
20+
@responses = {}
21+
@requests = []
22+
@writes = []
23+
end
24+
25+
def readlines(*arguments, **options)
26+
return super unless arguments.first == "gh"
27+
@requests << arguments
28+
case arguments[1, 2]
29+
when ["pr", "list"]
30+
[JSON.generate(@pulls)]
31+
when ["pr", "create"]
32+
@writes << File.read(arguments[arguments.index("--body-file") + 1])
33+
["https://github.com/socketry/example/pull/42\n"]
34+
else
35+
[JSON.generate(@responses.fetch(arguments.fetch(2)))]
36+
end
37+
end
38+
39+
def system(*arguments, **options)
40+
return super unless arguments.first == "gh"
41+
@requests << arguments
42+
@writes << JSON.parse(File.read(arguments[arguments.index("--input") + 1]))
43+
true
44+
end
45+
end
46+
end
47+
end
48+
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "sus/shared"
7+
require "sus/fixtures/temporary_directory_context"
8+
require "sus/fixtures/isolated_ruby_context"
9+
require "bake/gem/github/setup"
10+
11+
module Bake
12+
module Gem
13+
module GitHub
14+
RepositoryContext = Sus::Shared("a gem repository") do
15+
include Sus::Fixtures::TemporaryDirectoryContext
16+
include Sus::Fixtures::IsolatedRubyContext
17+
18+
def repository
19+
File.join(root, "source")
20+
end
21+
22+
def git(*arguments, chdir: repository)
23+
output = IO.popen(["git", *arguments], chdir: chdir, &:read)
24+
raise "git failed: #{arguments.inspect}" unless $?.success?
25+
output.strip
26+
end
27+
28+
before do
29+
FileUtils.mkdir_p(File.join(repository, "lib/example"))
30+
File.write(File.join(repository, "lib/example/version.rb"), "module Example; VERSION = \"1.0.0\"; end\n")
31+
File.write(File.join(repository, "example.gemspec"), <<~RUBY)
32+
require_relative "lib/example/version"
33+
Gem::Specification.new do |spec|
34+
spec.name = "example"
35+
spec.version = Example::VERSION
36+
spec.summary = "Example consumer"
37+
spec.authors = ["Test"]
38+
spec.license = "MIT"
39+
spec.homepage = "https://example.com"
40+
spec.required_ruby_version = ">= 3.3"
41+
spec.files = Dir.glob(["lib/**/*.rb", "releases.md"])
42+
end
43+
RUBY
44+
File.write(File.join(repository, "releases.md"), "## Unreleased\n\nA change.\n")
45+
File.write(File.join(repository, "bake.rb"), <<~'RUBY')
46+
def after_gem_release_version_increment(version)
47+
File.write("releases.md", File.read("releases.md").sub("Unreleased", version.to_s))
48+
end
49+
RUBY
50+
File.write(File.join(repository, ".gitignore"), "/pkg/\n")
51+
Setup.new(repository).generate(repository: "socketry/example", checks: ["Tests"], signing: false)
52+
git("init", "--quiet", "--initial-branch=main")
53+
git("config", "core.hooksPath", File::NULL)
54+
git("config", "commit.gpgsign", "false")
55+
git("config", "user.name", "Test")
56+
git("config", "user.email", "test@example.com")
57+
git("add", "--all")
58+
git("commit", "--quiet", "-m", "Initial source")
59+
git("init", "--quiet", "--bare", "--initial-branch=main", File.join(root, "remote"))
60+
git("remote", "add", "origin", File.join(root, "remote"))
61+
git("push", "--quiet", "--set-upstream", "origin", "main")
62+
end
63+
64+
def isolated_project(source, env: {})
65+
isolated_ruby(source, chdir: repository, env: env, requires: ["bundler/setup", File.expand_path("project_client.rb", __dir__)])
66+
end
67+
end
68+
end
69+
end
70+
end

‎gems.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
gemspec
66

77
group :test do
8+
gem "bake-test"
89
gem "covered"
910
gem "sus"
1011
gem "rubocop"

‎test/bake/gem/github/project.rb‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "bake/gem/github/repository_context"
7+
require "bake/gem/github/project_client"
8+
9+
describe Bake::Gem::GitHub::Project do
10+
include Bake::Gem::GitHub::RepositoryContext
11+
12+
with "#prepare" do
13+
it "prepares, validates, and pushes a release before opening its PR" do
14+
result = isolated_project(<<~'RUBY')
15+
project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd)
16+
url = project.prepare(Bake::Context.load(Dir.pwd), "patch")
17+
{url: url, body: project.writes.first}
18+
RUBY
19+
expect(result[:url]).to be == "https://github.com/socketry/example/pull/42"
20+
expect(result[:body]).to be(:include?, "Release example 1.0.1.")
21+
expect(git("branch", "--show-current")).to be == "releases/v1.0.1"
22+
expect(git("rev-parse", "HEAD")).to be == git("rev-parse", "refs/heads/releases/v1.0.1", chdir: File.join(root, "remote"))
23+
expect(File.read(File.join(repository, "releases.md"))).to be(:include?, "## v1.0.1")
24+
expect(git("tag", "--list")).to be == ""
25+
git("checkout", "--quiet", "main")
26+
git("merge", "--quiet", "--no-ff", "releases/v1.0.1", "-m", "Merge release")
27+
git("push", "--quiet", "origin", "main")
28+
commit = git("rev-parse", "HEAD")
29+
project = Bake::Gem::GitHub::ProjectClient.new(repository)
30+
project.responses["repos/socketry/example/pulls/42"] = {
31+
"number" => 42, "merged" => true, "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}},
32+
"merge_commit_sha" => commit, "merged_by" => {"login" => "maintainer"}, "html_url" => result[:url]
33+
}
34+
evidence = project.inspect_release(42)
35+
expect(evidence).to have_keys(name: be == "example", version: be == "1.0.1", commit: be == commit, merged_by: be == "maintainer")
36+
end
37+
38+
it "reports an existing release PR without creating a branch" do
39+
url = isolated_project(<<~'RUBY')
40+
project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd)
41+
project.pulls = [{"headRefName" => "releases/v1.0.1", "url" => "existing"}]
42+
project.prepare(Bake::Context.load(Dir.pwd), "patch")
43+
RUBY
44+
expect(url).to be == "existing"
45+
expect(git("branch", "--show-current")).to be == "main"
46+
end
47+
48+
it "refuses preparation from another branch" do
49+
git("checkout", "--quiet", "-b", "feature")
50+
expect do
51+
isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")')
52+
end.to raise_exception(RuntimeError, message: be =~ /Prepare releases from main/)
53+
end
54+
55+
it "refuses preparation when the local default branch differs from origin" do
56+
git("commit", "--quiet", "--allow-empty", "-m", "Local change")
57+
expect do
58+
isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")')
59+
end.to raise_exception(RuntimeError, message: be =~ /differs from origin/)
60+
end
61+
end
62+
63+
with "#merged and #inspect_release" do
64+
let(:project) {Bake::Gem::GitHub::ProjectClient.new(repository)}
65+
let(:pull) {{"merged" => true, "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}}, "merge_commit_sha" => git("rev-parse", "HEAD")}}
66+
67+
before do
68+
project.responses["repos/socketry/example/pulls/42"] = pull
69+
end
70+
71+
it "accepts a merged commit in the remote default branch history" do
72+
git("commit", "--quiet", "--allow-empty", "-m", "Later development")
73+
git("push", "--quiet", "origin", "main")
74+
expect(project.merged(42)).to be == pull
75+
end
76+
77+
it "rejects a commit outside the remote default branch history" do
78+
git("checkout", "--quiet", "-b", "unmerged")
79+
git("commit", "--quiet", "--allow-empty", "-m", "Unmerged change")
80+
pull["merge_commit_sha"] = git("rev-parse", "HEAD")
81+
expect{project.merged(42)}.to raise_exception(Bake::Gem::CommandExecutionError)
82+
end
83+
84+
it "rejects invalid commit identifiers" do
85+
pull["merge_commit_sha"] = "--help"
86+
expect{project.merged(42)}.to raise_exception(RuntimeError, message: be =~ /Invalid merged commit/)
87+
end
88+
89+
it "does not publish ordinary merged changes" do
90+
File.write(File.join(repository, "readme.md"), "An ordinary change.\n")
91+
git("add", "readme.md")
92+
git("commit", "--quiet", "-m", "Documentation")
93+
git("push", "--quiet", "origin", "main")
94+
pull["merge_commit_sha"] = git("rev-parse", "HEAD")
95+
expect(project.inspect_release(42)).to be_nil
96+
end
97+
end
98+
99+
with "#doctor and #apply" do
100+
let(:project) {Bake::Gem::GitHub::ProjectClient.new(repository)}
101+
let(:rules) {[]}
102+
103+
before do
104+
project.responses["repos/socketry/example/rulesets?per_page=100"] = rules
105+
project.responses["repos/socketry/example/environments"] = {"environments" => []}
106+
end
107+
108+
it "reports desired and observed settings without writing" do
109+
expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project)
110+
result = Bake::Context.load(repository).call("gem:github:setup:plan")
111+
expect(result[:desired_rules].keys).to be == %w[reviews checks history tags]
112+
expect(result[:existing_rules]).to be == []
113+
expect(result[:trusted_publisher][:workflow_filename]).to be == "release-publish.yaml"
114+
expect(project.writes).to be == []
115+
end
116+
117+
it "creates missing managed rulesets while preserving unrelated rulesets" do
118+
rules << {"id" => 123, "name" => "Unrelated policy"}
119+
expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project)
120+
Bake::Context.load(repository).call("gem:github:setup:apply")
121+
expect(project.writes.map{|rule| rule.fetch("name")}).to be == ["Gem release reviews", "Gem release checks", "Gem release history", "Gem release tags"]
122+
expect(project.requests.drop(1).all?{|request| request.include?("POST")}).to be == true
123+
end
124+
125+
it "updates existing managed rulesets by ID" do
126+
Bake::Gem::GitHub::Setup.rules(project.config).values.each_with_index do |rule, index|
127+
rules << {"id" => index + 1, "name" => rule.fetch(:name)}
128+
end
129+
project.apply
130+
expect(project.requests.drop(1).map{|request| request[2]}).to be == (1..4).map{|id| "repos/socketry/example/rulesets/#{id}"}
131+
expect(project.requests.drop(1).all?{|request| request.include?("PUT")}).to be == true
132+
end
133+
134+
it "refuses ambiguous managed rulesets" do
135+
rules.concat([{"id" => 1, "name" => "Gem release reviews"}, {"id" => 2, "name" => "Gem release reviews"}])
136+
expect{project.apply}.to raise_exception(RuntimeError, message: be =~ /Multiple rulesets/)
137+
expect(project.writes).to be == []
138+
end
139+
end
140+
end

0 commit comments

Comments
 (0)