diff --git a/.github/releasing.md b/.github/releasing.md index 422d689..3554cd2 100644 --- a/.github/releasing.md +++ b/.github/releasing.md @@ -13,3 +13,5 @@ If a publishing job fails, use **Re-run all jobs** on that same workflow run, or 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. 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. + +After upgrading `bake-gem-github`, start from a clean working tree and run `bundle exec bake gem:github:setup:update`. It updates generated files without staging or committing them. Review `git diff`, selectively keep changes and restore customizations as needed, then open a PR. diff --git a/bake/gem/github/setup.rb b/bake/gem/github/setup.rb index d64f7ab..689bd58 100644 --- a/bake/gem/github/setup.rb +++ b/bake/gem/github/setup.rb @@ -13,3 +13,9 @@ def apply require_relative "../../../lib/bake/gem/github/project" Bake::Gem::GitHub::Project.new(context.root).apply end + +# Update generated files in the working tree using config/release.yaml and the installed templates. +def update + require_relative "../../../lib/bake/gem/github/setup" + Bake::Gem::GitHub::Setup.new(context.root).update +end diff --git a/context/getting-started.md b/context/getting-started.md index dcd26fc..7d874f3 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -18,7 +18,16 @@ bundle exec bake agent:context:install bundle exec bake gem:github:setup:plan ``` -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. +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. + +To adopt template fixes after upgrading the gem, start from a clean working tree, edit `config/release.yaml` as needed, and regenerate: + +``` bash +bundle exec bake gem:github:setup:update +git diff +``` + +The task updates the managed workflows, policy payloads, configuration formatting, and maintainer instructions directly in the working tree and returns the changed paths. It does not stage, commit, or change remote settings. An agent or maintainer can review the diff and selectively keep changes, restoring repository-specific customizations from Git where needed. Commit or stash existing edits first: generated files are replaced by the current templates. Repeating an update produces no further changes; intentionally retained customizations will appear in later update diffs. Apply remote rulesets after the corresponding workflows are running. 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. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index dcd26fc..7d874f3 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -18,7 +18,16 @@ bundle exec bake agent:context:install bundle exec bake gem:github:setup:plan ``` -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. +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. + +To adopt template fixes after upgrading the gem, start from a clean working tree, edit `config/release.yaml` as needed, and regenerate: + +``` bash +bundle exec bake gem:github:setup:update +git diff +``` + +The task updates the managed workflows, policy payloads, configuration formatting, and maintainer instructions directly in the working tree and returns the changed paths. It does not stage, commit, or change remote settings. An agent or maintainer can review the diff and selectively keep changes, restoring repository-specific customizations from Git where needed. Commit or stash existing edits first: generated files are replaced by the current templates. Repeating an update produces no further changes; intentionally retained customizations will appear in later update diffs. Apply remote rulesets after the corresponding workflows are running. 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. diff --git a/lib/bake/gem/github/setup.rb b/lib/bake/gem/github/setup.rb index 321335a..2ffb40a 100644 --- a/lib/bake/gem/github/setup.rb +++ b/lib/bake/gem/github/setup.rb @@ -25,25 +25,20 @@ def generate(repository:, branch: "main", checks:, approvals: 2, signing: File.f raise "Select the required CI check names." if checks.empty? raise "Review count must be between 1 and 6." unless (1..6).include?(approvals) config = {"schema" => 1, "repository" => repository, "branch" => branch, "checks" => (checks + ["Release validation"]).uniq, "approvals" => approvals, "signing" => signing, "ruby" => ruby, "environment" => "rubygems"} - templates = File.expand_path("../../../../templates", __dir__) - files = {"config/release.yaml" => YAML.dump(config)} - Dir.glob("*.erb", base: templates).each do |name| - files[".github/workflows/#{name.delete_suffix('.erb')}"] = ERB.new(File.read(File.join(templates, name)), trim_mode: "-").result(binding) - end - self.class.rules(config).each do |name, rule| - files[".github/release-rules/#{name}.json"] = JSON.pretty_generate(rule) + "\n" - end - files[".github/releasing.md"] = File.read(File.join(templates, "releasing.md")) + files = render(config) conflicts = files.keys.select{|name| File.exist?(File.join(@root, name)) && File.read(File.join(@root, name)) != files[name]} raise "Existing files differ; review them before regenerating: #{conflicts.join(', ')}" unless conflicts.empty? - files.each do |name, content| - path = File.join(@root, name) - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, content) unless File.exist?(path) - end + write(files) files.keys end + # Update generated files in the working tree using the existing configuration; return changed paths. + def update + config = YAML.safe_load_file(File.join(@root, "config/release.yaml")) + raise "Unsupported release configuration." unless config.fetch("schema") == 1 + write(render(config)) + end + # Native review/check rules allow PR-only administrator bypass; history rules have no bypass. def self.rules(config) conditions = {ref_name: {include: ["refs/heads/#{config.fetch('branch')}"], exclude: []}} @@ -56,6 +51,34 @@ def self.rules(config) "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"}]} } end + + private + + def write(files) + files.filter_map do |name, content| + path = File.join(@root, name) + next if File.exist?(path) && File.read(path) == content + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + name + end + end + + def render(config) + branch = config.fetch("branch") + ruby = config.fetch("ruby") + signing = config.fetch("signing") + templates = File.expand_path("../../../../templates", __dir__) + files = {"config/release.yaml" => YAML.dump(config)} + Dir.glob("*.erb", base: templates).each do |name| + files[".github/workflows/#{name.delete_suffix('.erb')}"] = ERB.new(File.read(File.join(templates, name)), trim_mode: "-").result(binding) + end + self.class.rules(config).each do |name, rule| + files[".github/release-rules/#{name}.json"] = JSON.pretty_generate(rule) + "\n" + end + files[".github/releasing.md"] = File.read(File.join(templates, "releasing.md")) + files + end end end end diff --git a/releases.md b/releases.md index 7da9799..0c9389c 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Update generated release files in the working tree with `gem:github:setup:update`. + ## v0.0.5 - Preserve and recover release files even when GitHub's release list is stale. diff --git a/templates/releasing.md b/templates/releasing.md index 422d689..3554cd2 100644 --- a/templates/releasing.md +++ b/templates/releasing.md @@ -13,3 +13,5 @@ If a publishing job fails, use **Re-run all jobs** on that same workflow run, or 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. 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. + +After upgrading `bake-gem-github`, start from a clean working tree and run `bundle exec bake gem:github:setup:update`. It updates generated files without staging or committing them. Review `git diff`, selectively keep changes and restore customizations as needed, then open a PR. diff --git a/test/bake/gem/github/setup/update.rb b/test/bake/gem/github/setup/update.rb new file mode 100644 index 0000000..410489d --- /dev/null +++ b/test/bake/gem/github/setup/update.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/setup" +require "bake/gem/github/repository_context" + +describe Bake::Gem::GitHub::Setup do + include Bake::Gem::GitHub::RepositoryContext + + let(:setup) {subject.new(repository)} + + with "#update" do + it "leaves generated updates in the working tree for selective review" do + workflow = File.join(repository, ".github/workflows/release-validate.yaml") + File.write(workflow, "# Custom workflow\n" + File.read(workflow)) + File.write(File.join(repository, ".github/workflows/custom.yaml"), "Custom workflow\n") + git("add", "--all") + git("commit", "--quiet", "-m", "Repository customizations") + original = git("rev-parse", "HEAD") + path = File.join(repository, "config/release.yaml") + config = YAML.safe_load_file(path) + config["approvals"] = 3 + config["checks"] << "New check" + File.write(path, YAML.dump(config)) + registry = Bake::Registry::Aggregate.new + registry.append_path(::Gem.loaded_specs.fetch("bake-gem-github").full_gem_path) + changed = Bake::Context.new(registry, repository).call("gem:github:setup:update") + expect(changed.sort).to be == [".github/release-rules/checks.json", ".github/release-rules/reviews.json", ".github/workflows/release-validate.yaml"] + diff = git("diff") + expect(diff).to be(:include?, "-# Custom workflow") + expect(diff).to be(:include?, '+ "context": "New check"') + expect(git("rev-parse", "HEAD")).to be == original + expect(git("diff", "--cached")).to be == "" + expect(File.read(File.join(repository, ".github/workflows/custom.yaml"))).to be == "Custom workflow\n" + expect(setup.update).to be == [] + expect(git("diff")).to be == diff + expect(YAML.safe_load_file(path)).to be == config + end + + it "restores missing generated files" do + path = File.join(repository, ".github/releasing.md") + original = File.read(path) + File.unlink(path) + expect(setup.update).to be == [".github/releasing.md"] + expect(File.read(path)).to be == original + expect(setup.update).to be == [] + end + + it "refuses unsupported configuration schemas" do + File.write(File.join(repository, "config/release.yaml"), YAML.dump("schema" => 2)) + expect{setup.update}.to raise_exception(RuntimeError, message: be =~ /Unsupported release configuration/) + end + end +end