Skip to content

Commit 0370ecf

Browse files
Generate reviewable patches for release setup updates
1 parent 4658aa3 commit 0370ecf

8 files changed

Lines changed: 138 additions & 11 deletions

File tree

‎.github/releasing.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ If a publishing job fails, use **Re-run all jobs** on that same workflow run, or
1313
Verify both the downloaded gem and `release.json` with `gh attestation verify`, enforcing the publishing workflow's certificate identity and default branch ref. Then check the signed receipt's source commit and gem digest; follow the companion gem's setup guide for the complete commands. `--source-digest` checks the workflow revision, which can differ from the release commit. Release assets include the gem, RubyGems Sigstore bundle, GitHub provenance bundle, and signed source/digest receipt.
1414

1515
The initial implementation supports public repositories, a single gemspec, stable patch/minor/major versions, and merge/squash commits. Keep rebase merges and merge queues disabled. Generation hooks must be repeatable from the same source and version. Run setup and account recovery checks before enabling publishing; see the companion gem's setup guide.
16+
17+
After upgrading `bake-gem-github`, run `bundle exec bake gem:github:setup:update` to write `pkg/release-setup.patch`. Review every hunk, including differences from local customizations, then apply the desired changes and open a PR. The task does not modify repository files or remote settings.

‎bake/gem/github/setup.rb‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ def apply
1313
require_relative "../../../lib/bake/gem/github/project"
1414
Bake::Gem::GitHub::Project.new(context.root).apply
1515
end
16+
17+
# Write a reviewable update patch using config/release.yaml and the installed templates.
18+
def update
19+
require_relative "../../../lib/bake/gem/github/setup"
20+
Bake::Gem::GitHub::Setup.new(context.root).update
21+
end

‎context/getting-started.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,19 @@ bundle exec bake agent:context:install
1818
bundle exec bake gem:github:setup:plan
1919
```
2020

21-
Setup adds three pinned workflows, `config/release.yaml`, native ruleset payloads, and `.github/releasing.md`. Identical reruns do nothing; conflicting existing files stop before any file is written. For later policy changes, edit the configuration and review the generated workflow/policy changes explicitly. Setup does not replace other publishers: remove conflicting release workflows during migration.
21+
Setup adds three pinned workflows, `config/release.yaml`, native ruleset payloads, and `.github/releasing.md`. Identical reruns do nothing; conflicting existing files stop before any file is written. Setup does not replace other publishers: remove conflicting release workflows during migration.
22+
23+
To adopt template fixes after upgrading the gem, or change policy, edit `config/release.yaml` as needed and generate an update patch:
24+
25+
``` bash
26+
bundle exec bake gem:github:setup:update
27+
cat pkg/release-setup.patch
28+
git apply --check pkg/release-setup.patch
29+
git apply pkg/release-setup.patch
30+
git diff
31+
```
32+
33+
The task leaves repository files untouched and writes only `pkg/release-setup.patch`. The patch proposes the current templates, policy payloads, configuration formatting, and maintainer instructions. It includes any differences from repository customizations: inspect every hunk, remove unwanted hunks or apply selected changes manually, then commit the result through a PR. Repeating the task after applying all changes produces an empty patch. Intentionally retained customizations remain visible on later updates. Apply remote rulesets only after the corresponding workflows are merged and their checks are running.
2234

2335
The default is two approvals with explicit administrator bypass, dismissed stale reviews, approval of the last push, strict up-to-date CI, and immutable default-branch history/release tags. These branch rules affect **all PRs** into the default branch. Ordinary administrator reviews count as one review. A human who dispatches a bot-authored PR is not its author under GitHub's native rules.
2436

‎guides/getting-started/readme.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,19 @@ bundle exec bake agent:context:install
1818
bundle exec bake gem:github:setup:plan
1919
```
2020

21-
Setup adds three pinned workflows, `config/release.yaml`, native ruleset payloads, and `.github/releasing.md`. Identical reruns do nothing; conflicting existing files stop before any file is written. For later policy changes, edit the configuration and review the generated workflow/policy changes explicitly. Setup does not replace other publishers: remove conflicting release workflows during migration.
21+
Setup adds three pinned workflows, `config/release.yaml`, native ruleset payloads, and `.github/releasing.md`. Identical reruns do nothing; conflicting existing files stop before any file is written. Setup does not replace other publishers: remove conflicting release workflows during migration.
22+
23+
To adopt template fixes after upgrading the gem, or change policy, edit `config/release.yaml` as needed and generate an update patch:
24+
25+
``` bash
26+
bundle exec bake gem:github:setup:update
27+
cat pkg/release-setup.patch
28+
git apply --check pkg/release-setup.patch
29+
git apply pkg/release-setup.patch
30+
git diff
31+
```
32+
33+
The task leaves repository files untouched and writes only `pkg/release-setup.patch`. The patch proposes the current templates, policy payloads, configuration formatting, and maintainer instructions. It includes any differences from repository customizations: inspect every hunk, remove unwanted hunks or apply selected changes manually, then commit the result through a PR. Repeating the task after applying all changes produces an empty patch. Intentionally retained customizations remain visible on later updates. Apply remote rulesets only after the corresponding workflows are merged and their checks are running.
2234

2335
The default is two approvals with explicit administrator bypass, dismissed stale reviews, approval of the last push, strict up-to-date CI, and immutable default-branch history/release tags. These branch rules affect **all PRs** into the default branch. Ordinary administrator reviews count as one review. A human who dispatches a bot-authored PR is not its author under GitHub's native rules.
2436

‎lib/bake/gem/github/setup.rb‎

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require "yaml"
88
require "json"
99
require "fileutils"
10+
require "tempfile"
1011

1112
module Bake
1213
module Gem
@@ -25,15 +26,7 @@ def generate(repository:, branch: "main", checks:, approvals: 2, signing: File.f
2526
raise "Select the required CI check names." if checks.empty?
2627
raise "Review count must be between 1 and 6." unless (1..6).include?(approvals)
2728
config = {"schema" => 1, "repository" => repository, "branch" => branch, "checks" => (checks + ["Release validation"]).uniq, "approvals" => approvals, "signing" => signing, "ruby" => ruby, "environment" => "rubygems"}
28-
templates = File.expand_path("../../../../templates", __dir__)
29-
files = {"config/release.yaml" => YAML.dump(config)}
30-
Dir.glob("*.erb", base: templates).each do |name|
31-
files[".github/workflows/#{name.delete_suffix('.erb')}"] = ERB.new(File.read(File.join(templates, name)), trim_mode: "-").result(binding)
32-
end
33-
self.class.rules(config).each do |name, rule|
34-
files[".github/release-rules/#{name}.json"] = JSON.pretty_generate(rule) + "\n"
35-
end
36-
files[".github/releasing.md"] = File.read(File.join(templates, "releasing.md"))
29+
files = render(config)
3730
conflicts = files.keys.select{|name| File.exist?(File.join(@root, name)) && File.read(File.join(@root, name)) != files[name]}
3831
raise "Existing files differ; review them before regenerating: #{conflicts.join(', ')}" unless conflicts.empty?
3932
files.each do |name, content|
@@ -44,6 +37,27 @@ def generate(repository:, branch: "main", checks:, approvals: 2, signing: File.f
4437
files.keys
4538
end
4639

40+
# Write a patch proposing updates from the existing configuration, without replacing repository files.
41+
def update
42+
config = YAML.safe_load_file(File.join(@root, "config/release.yaml"))
43+
raise "Unsupported release configuration." unless config.fetch("schema") == 1
44+
patch = render(config).map do |name, content|
45+
path = File.join(@root, name)
46+
Tempfile.create("release-update") do |file|
47+
file.write(content)
48+
file.flush
49+
exists = File.exist?(path)
50+
diff = IO.popen(["diff", "-u", "--label", exists ? "a/#{name}" : "/dev/null", "--label", "b/#{name}", exists ? path : File::NULL, file.path], &:read)
51+
raise "Unable to compare #{name}." unless [0, 1].include?($?.exitstatus)
52+
diff
53+
end
54+
end.join
55+
path = File.join(@root, "pkg/release-setup.patch")
56+
FileUtils.mkdir_p(File.dirname(path))
57+
File.write(path, patch)
58+
path
59+
end
60+
4761
# Native review/check rules allow PR-only administrator bypass; history rules have no bypass.
4862
def self.rules(config)
4963
conditions = {ref_name: {include: ["refs/heads/#{config.fetch('branch')}"], exclude: []}}
@@ -56,6 +70,24 @@ def self.rules(config)
5670
"tags" => {name: "Gem release tags", target: "tag", enforcement: "active", bypass_actors: [], conditions: {ref_name: {include: ["refs/tags/v*"], exclude: []}}, rules: [{type: "deletion"}, {type: "non_fast_forward"}]}
5771
}
5872
end
73+
74+
private
75+
76+
def render(config)
77+
branch = config.fetch("branch")
78+
ruby = config.fetch("ruby")
79+
signing = config.fetch("signing")
80+
templates = File.expand_path("../../../../templates", __dir__)
81+
files = {"config/release.yaml" => YAML.dump(config)}
82+
Dir.glob("*.erb", base: templates).each do |name|
83+
files[".github/workflows/#{name.delete_suffix('.erb')}"] = ERB.new(File.read(File.join(templates, name)), trim_mode: "-").result(binding)
84+
end
85+
self.class.rules(config).each do |name, rule|
86+
files[".github/release-rules/#{name}.json"] = JSON.pretty_generate(rule) + "\n"
87+
end
88+
files[".github/releasing.md"] = File.read(File.join(templates, "releasing.md"))
89+
files
90+
end
5991
end
6092
end
6193
end

‎releases.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Generate reviewable setup updates with `gem:github:setup:update`.
6+
37
## v0.0.5
48

59
- Preserve and recover release files even when GitHub's release list is stale.

‎templates/releasing.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ If a publishing job fails, use **Re-run all jobs** on that same workflow run, or
1313
Verify both the downloaded gem and `release.json` with `gh attestation verify`, enforcing the publishing workflow's certificate identity and default branch ref. Then check the signed receipt's source commit and gem digest; follow the companion gem's setup guide for the complete commands. `--source-digest` checks the workflow revision, which can differ from the release commit. Release assets include the gem, RubyGems Sigstore bundle, GitHub provenance bundle, and signed source/digest receipt.
1414

1515
The initial implementation supports public repositories, a single gemspec, stable patch/minor/major versions, and merge/squash commits. Keep rebase merges and merge queues disabled. Generation hooks must be repeatable from the same source and version. Run setup and account recovery checks before enabling publishing; see the companion gem's setup guide.
16+
17+
After upgrading `bake-gem-github`, run `bundle exec bake gem:github:setup:update` to write `pkg/release-setup.patch`. Review every hunk, including differences from local customizations, then apply the desired changes and open a PR. The task does not modify repository files or remote settings.

‎test/setup_update.rb‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "bake/gem/github/setup"
7+
require "bake/context"
8+
require "sus/fixtures/temporary_directory_context"
9+
10+
describe Bake::Gem::GitHub::Setup do
11+
include Sus::Fixtures::TemporaryDirectoryContext
12+
13+
let(:setup) {subject.new(root)}
14+
15+
before do
16+
setup.generate(repository: "socketry/example", checks: ["Tests"], signing: false)
17+
end
18+
19+
it "proposes configuration changes and customizations without overwriting them" do
20+
path = File.join(root, "config/release.yaml")
21+
config = YAML.safe_load_file(path)
22+
config["approvals"] = 3
23+
config["checks"] << "New check"
24+
File.write(path, YAML.dump(config))
25+
workflow = File.join(root, ".github/workflows/release-validate.yaml")
26+
File.write(workflow, "# Custom workflow\n" + File.read(workflow))
27+
before = Dir.glob("{config,.github}/**/*", File::FNM_DOTMATCH, base: root).select{|name| File.file?(File.join(root, name))}.to_h{|name| [name, File.read(File.join(root, name))]}
28+
registry = Bake::Registry::Aggregate.new
29+
registry.append_path(File.expand_path("..", __dir__))
30+
patch = Bake::Context.new(registry, root).call("gem:github:setup:update")
31+
expect(File.read(patch)).to be(:include?, "-# Custom workflow")
32+
expect(File.read(patch)).to be(:include?, '+ "context": "New check"')
33+
before.each do |name, content|
34+
expect(File.read(File.join(root, name))).to be == content
35+
end
36+
expect(system("git", "apply", "--check", patch, chdir: root)).to be == true
37+
expect(system("git", "apply", patch, chdir: root)).to be == true
38+
expect(File.read(setup.update)).to be == ""
39+
expect(YAML.safe_load_file(path)).to be == config
40+
end
41+
42+
it "restores missing generated files through the patch" do
43+
path = File.join(root, ".github/releasing.md")
44+
original = File.read(path)
45+
File.unlink(path)
46+
patch = setup.update
47+
expect(File.exist?(path)).to be == false
48+
expect(system("git", "apply", patch, chdir: root)).to be == true
49+
expect(File.read(path)).to be == original
50+
expect(File.read(setup.update)).to be == ""
51+
end
52+
53+
it "refuses unsupported configuration schemas" do
54+
File.write(File.join(root, "config/release.yaml"), YAML.dump("schema" => 2))
55+
expect{setup.update}.to raise_exception(RuntimeError, message: be =~ /Unsupported release configuration/)
56+
end
57+
end

0 commit comments

Comments
 (0)