From 355ce520841aff9dfdcd99395aea9e5dee85fb4d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 21 Sep 2026 23:35:10 +1200 Subject: [PATCH] Resume and refresh release preparation with preserved branches --- .github/workflows/release-prepare.yaml | 7 +- bake/gem/github/release.rb | 15 +-- context/getting-started.md | 16 ++- guides/getting-started/readme.md | 16 ++- lib/bake/gem/github/project.rb | 52 ++++++++-- releases.md | 1 + templates/release-prepare.yaml.erb | 7 +- test/bake/gem/github/project.rb | 6 +- test/bake/gem/github/project/prepare.rb | 123 ++++++++++++++++++++++++ 9 files changed, 220 insertions(+), 23 deletions(-) create mode 100644 test/bake/gem/github/project/prepare.rb diff --git a/.github/workflows/release-prepare.yaml b/.github/workflows/release-prepare.yaml index 714a39f..939f561 100644 --- a/.github/workflows/release-prepare.yaml +++ b/.github/workflows/release-prepare.yaml @@ -8,6 +8,10 @@ on: required: true type: choice options: [patch, minor, major] + refresh: + description: Preserve and regenerate an existing release branch + type: boolean + default: false permissions: contents: write @@ -38,10 +42,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} BUMP: ${{ inputs.bump }} + REFRESH: ${{ inputs.refresh }} run: | # GitHub.com's shared Actions bot ID; this identity is not for GitHub Enterprise Server. # https://github.com/actions/checkout#push-a-commit-using-the-built-in-token git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' case "$BUMP" in patch|minor|major) ;; *) exit 1 ;; esac - bundle exec bake "gem:github:release:$BUMP" + bundle exec bake "gem:github:release:$BUMP" "refresh=$REFRESH" diff --git a/bake/gem/github/release.rb b/bake/gem/github/release.rb index d9a7d11..6bbbeee 100644 --- a/bake/gem/github/release.rb +++ b/bake/gem/github/release.rb @@ -6,18 +6,21 @@ require_relative "../../../lib/bake/gem/github/publisher" # Prepare a patch release and open its PR. -def patch - Bake::Gem::GitHub::Project.new(context.root).prepare(context, "patch") +# @parameter refresh [Boolean] Preserve and regenerate an existing release branch. +def patch(refresh: false) + Bake::Gem::GitHub::Project.new(context.root).prepare(context, "patch", refresh: refresh) end # Prepare a minor release and open its PR. -def minor - Bake::Gem::GitHub::Project.new(context.root).prepare(context, "minor") +# @parameter refresh [Boolean] Preserve and regenerate an existing release branch. +def minor(refresh: false) + Bake::Gem::GitHub::Project.new(context.root).prepare(context, "minor", refresh: refresh) end # Prepare a major release and open its PR. -def major - Bake::Gem::GitHub::Project.new(context.root).prepare(context, "major") +# @parameter refresh [Boolean] Preserve and regenerate an existing release branch. +def major(refresh: false) + Bake::Gem::GitHub::Project.new(context.root).prepare(context, "major", refresh: refresh) end # Resolve and validate a merged PR, emitting a commit output for the publishing job. diff --git a/context/getting-started.md b/context/getting-started.md index 6ec59a8..cc1fa25 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -60,11 +60,23 @@ bundle exec bake gem:github:release:patch gh workflow run release-prepare.yaml -f bump=patch ``` -Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and reports an existing release PR instead of opening another. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed. +Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and validates an existing release PR before returning its URL. A matching local or remote branch is reused if PR creation was interrupted. Multiple open release PRs or a different requested bump stop preparation. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed. All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned. -If regeneration fails, prepare a new branch from the current default branch and review the new diff. Preserve manual release-branch edits separately. Automatic refresh/force-push is not implemented. A failure during preparation leaves the branch and generated changes available for inspection. +If preparation stops after creating or pushing the release branch, return to the current default branch and repeat the same command. The existing branch is validated and reused, so retries do not create a second version bump or PR. Resolve any uncommitted changes before switching branches. + +When validation reports stale content, explicitly refresh the same release: + +``` bash +git switch main +git pull --ff-only +bundle exec bake gem:github:release:patch refresh=true +# Or dispatch remotely: +gh workflow run release-prepare.yaml -f bump=patch -f refresh=true +``` + +Refresh first pushes the complete previous release commit to `release-backups/vVERSION/OLD_SHA`, including manual edits. It then regenerates in a clean worktree from the current default branch, validates, and updates the existing release branch using an explicit `--force-with-lease`. A concurrent remote edit causes the push to fail. Existing local release branches are left intact. Review the backup against the refreshed PR; incorporate necessary manual changes into the default branch or generation hooks and refresh again. Keep the backup until that review is complete. Replace `main` and `patch` with your configured branch and original bump type. ## Publish and verify diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 6ec59a8..cc1fa25 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -60,11 +60,23 @@ bundle exec bake gem:github:release:patch gh workflow run release-prepare.yaml -f bump=patch ``` -Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and reports an existing release PR instead of opening another. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed. +Replace `patch` with `minor` or `major`. The wrapper fetches the default branch and tags, refuses a stale local checkout, and validates an existing release PR before returning its URL. A matching local or remote branch is reused if PR creation was interrupted. Multiple open release PRs or a different requested bump stop preparation. GitHub's built-in token may require a writer to approve running workflows for its created PR; enable Actions' permission to create PRs. An organization-owned App token can be adopted later if automatic CI triggering is needed. All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned. -If regeneration fails, prepare a new branch from the current default branch and review the new diff. Preserve manual release-branch edits separately. Automatic refresh/force-push is not implemented. A failure during preparation leaves the branch and generated changes available for inspection. +If preparation stops after creating or pushing the release branch, return to the current default branch and repeat the same command. The existing branch is validated and reused, so retries do not create a second version bump or PR. Resolve any uncommitted changes before switching branches. + +When validation reports stale content, explicitly refresh the same release: + +``` bash +git switch main +git pull --ff-only +bundle exec bake gem:github:release:patch refresh=true +# Or dispatch remotely: +gh workflow run release-prepare.yaml -f bump=patch -f refresh=true +``` + +Refresh first pushes the complete previous release commit to `release-backups/vVERSION/OLD_SHA`, including manual edits. It then regenerates in a clean worktree from the current default branch, validates, and updates the existing release branch using an explicit `--force-with-lease`. A concurrent remote edit causes the push to fail. Existing local release branches are left intact. Review the backup against the refreshed PR; incorporate necessary manual changes into the default branch or generation hooks and refresh again. Keep the backup until that review is complete. Replace `main` and `patch` with your configured branch and original bump type. ## Publish and verify diff --git a/lib/bake/gem/github/project.rb b/lib/bake/gem/github/project.rb index 2ca1bc7..cd1d598 100644 --- a/lib/bake/gem/github/project.rb +++ b/lib/bake/gem/github/project.rb @@ -33,7 +33,7 @@ def api(path) end # Prepare a release through core Bake tasks, then push and create its pull request. - def prepare(context, bump) + def prepare(context, bump, refresh: false) Release::BUMPS.fetch(bump) helper = Helper.new(@root) helper.guard_clean @@ -41,19 +41,45 @@ def prepare(context, bump) raise "Prepare releases from #{branch}." unless helper.current_branch == branch system("git", "fetch", "origin", branch, "--tags", chdir: @root) raise "Local branch differs from origin/#{branch}." unless @release.resolve("HEAD") == @release.resolve("origin/#{branch}") - pulls = JSON.parse(readlines("gh", "pr", "list", "--repo", @repository, "--base", branch, "--state", "open", "--json", "headRefName,url", "--limit", "1000", chdir: @root).join) - if existing = pulls.find{|pr| pr.fetch("headRefName").start_with?("releases/v")} - return existing.fetch("url") - end + pulls = JSON.parse(readlines("gh", "pr", "list", "--repo", @repository, "--base", branch, "--state", "open", "--json", "headRefName,url,isCrossRepository", "--limit", "1000", chdir: @root).join) + pulls = pulls.select{|pr| !pr["isCrossRepository"] && pr.fetch("headRefName").start_with?("releases/v")} + raise "Multiple release PRs are open; select one before preparing another release." if pulls.size > 1 + existing = pulls.first + version = Version.new(helper.gemspec.version.segments, nil).increment(Release::BUMPS.fetch(bump)).join + name = "releases/v#{version}" + raise "Existing release PR uses #{existing.fetch('headRefName')}; use its bump type or close it first." if existing && existing.fetch("headRefName") != name base = @release.resolve("HEAD") - result = context.lookup("gem:release:branch:#{bump}").call - @release.validate(base: base) - system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "--set-upstream", "origin", result.fetch(:branch), chdir: @root) - body = "Release #{helper.gemspec.name} #{result.fetch(:version)}.\n\nPrepared from #{base}. The complete release tree is regenerated during validation. Merging publishes the resulting commit through release-publish.yaml after native reviews and required CI (or explicit administrator bypass).\n" + ref = "refs/heads/#{name}" + remote = readlines("git", "ls-remote", "--heads", "origin", ref, chdir: @root).first + if remote + system("git", "fetch", "origin", ref, chdir: @root) + remote = @release.resolve("FETCH_HEAD") + end + candidate = remote + if !candidate && readlines("git", "branch", "--list", name, chdir: @root).any? + candidate = @release.resolve(ref) + end + if candidate && refresh + # Preserve the complete previous tree before replacing the release branch: + backup = "refs/heads/release-backups/v#{version}/#{candidate}" + push("#{candidate}:#{backup}") + candidate = @release.worktree(base) do |path| + @release.bake(path, "gem:release:version:#{bump}") + readlines("git", "rev-parse", "HEAD", chdir: path).join.strip + end + elsif !candidate + context.lookup("gem:release:branch:#{bump}").call + candidate = @release.resolve("HEAD") + end + metadata = @release.validate(base: base, candidate: candidate) + raise "Release branch does not contain the requested version #{version}." unless metadata.fetch(:version) == version + push("--force-with-lease=#{ref}:#{remote}", "#{candidate}:#{ref}") + return existing.fetch("url") if existing + body = "Release #{helper.gemspec.name} #{version}.\n\nPrepared from #{base}. The complete release tree is regenerated during validation. Merging publishes the resulting commit through release-publish.yaml after native reviews and required CI (or explicit administrator bypass).\n" Tempfile.create("release-pr") do |file| file.write(body) file.flush - readlines("gh", "pr", "create", "--repo", @repository, "--base", branch, "--head", result.fetch(:branch), "--title", "Release v#{result.fetch(:version)}", "--body-file", file.path, chdir: @root).join.strip + readlines("gh", "pr", "create", "--repo", @repository, "--base", branch, "--head", name, "--title", "Release v#{version}", "--body-file", file.path, chdir: @root).join.strip end end @@ -105,6 +131,12 @@ def apply end end end + + private + + def push(*arguments) + system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "origin", *arguments, chdir: @root) + end end end end diff --git a/releases.md b/releases.md index b6cf03d..8d40259 100644 --- a/releases.md +++ b/releases.md @@ -12,6 +12,7 @@ - Include the version's release notes in GitHub releases using `bake-releases`. - Update generated release files in the working tree with `gem:github:setup:update`. + - Resume interrupted release preparation and explicitly refresh stale release PRs while preserving their previous commits. ## v0.0.5 diff --git a/templates/release-prepare.yaml.erb b/templates/release-prepare.yaml.erb index fb02c74..9594c1e 100644 --- a/templates/release-prepare.yaml.erb +++ b/templates/release-prepare.yaml.erb @@ -8,6 +8,10 @@ on: required: true type: choice options: [patch, minor, major] + refresh: + description: Preserve and regenerate an existing release branch + type: boolean + default: false permissions: contents: write @@ -38,10 +42,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} BUMP: ${{ inputs.bump }} + REFRESH: ${{ inputs.refresh }} run: | # GitHub.com's shared Actions bot ID; this identity is not for GitHub Enterprise Server. # https://github.com/actions/checkout#push-a-commit-using-the-built-in-token git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' case "$BUMP" in patch|minor|major) ;; *) exit 1 ;; esac - bundle exec bake "gem:github:release:$BUMP" + bundle exec bake "gem:github:release:$BUMP" "refresh=$REFRESH" diff --git a/test/bake/gem/github/project.rb b/test/bake/gem/github/project.rb index 5912dd8..37f50e7 100644 --- a/test/bake/gem/github/project.rb +++ b/test/bake/gem/github/project.rb @@ -35,7 +35,10 @@ 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 + it "validates an existing release PR without creating another" do + isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")') + original = git("rev-parse", "HEAD") + git("checkout", "--quiet", "main") url = isolated_project(<<~'RUBY') project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) project.pulls = [{"headRefName" => "releases/v1.0.1", "url" => "existing"}] @@ -43,6 +46,7 @@ RUBY expect(url).to be == "existing" expect(git("branch", "--show-current")).to be == "main" + expect(git("rev-parse", "releases/v1.0.1")).to be == original end it "refuses preparation from another branch" do diff --git a/test/bake/gem/github/project/prepare.rb b/test/bake/gem/github/project/prepare.rb new file mode 100644 index 0000000..6eb4dfa --- /dev/null +++ b/test/bake/gem/github/project/prepare.rb @@ -0,0 +1,123 @@ +# 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 + def prepare(refresh: false, pulls: []) + isolated_project(<<~'RUBY', env: {"REFRESH" => refresh.to_s, "PULLS" => JSON.generate(pulls)}) + project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) + project.pulls = JSON.parse(ENV.fetch("PULLS")) + project.prepare(Bake::Context.load(Dir.pwd), "patch", refresh: ENV.fetch("REFRESH") == "true") + RUBY + end + + let(:pull) {{"headRefName" => "releases/v1.0.1", "url" => "existing", "isCrossRepository" => false}} + + def advance_main + git("checkout", "--quiet", "main") + File.write(File.join(repository, "releases.md"), "## Unreleased\n\nAn additional change.\n") + git("add", "releases.md") + git("commit", "--quiet", "-m", "Document the additional change") + git("push", "--quiet", "origin", "main") + end + + it "resumes PR creation after the release branch was pushed" do + expect do + isolated_project(<<~'RUBY') + require "sus/mock" + project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) + Sus::Mock.new(project).wrap(:readlines) do |original, *arguments, **options| + raise "PR creation interrupted" if arguments[0, 3] == ["gh", "pr", "create"] + original.call(*arguments, **options) + end + project.prepare(Bake::Context.load(Dir.pwd), "patch") + RUBY + end.to raise_exception(RuntimeError, message: be =~ /PR creation interrupted/) + original = git("rev-parse", "HEAD") + git("checkout", "--quiet", "main") + expect(prepare).to be == "https://github.com/socketry/example/pull/42" + expect(git("rev-parse", "releases/v1.0.1", chdir: File.join(root, "remote"))).to be == original + end + + it "resumes a prepared local branch that has not been pushed" do + isolated_project('Bake::Context.load(Dir.pwd).call("gem:release:branch:patch")') + original = git("rev-parse", "HEAD") + git("checkout", "--quiet", "main") + prepare + expect(git("rev-parse", "releases/v1.0.1", chdir: File.join(root, "remote"))).to be == original + end + + it "rejects stale generated content without changing the release branch" do + prepare + original = git("rev-parse", "HEAD") + advance_main + expect{prepare(pulls: [pull])}.to raise_exception(RuntimeError, message: be =~ /Release content is stale/) + expect(git("rev-parse", "releases/v1.0.1", chdir: File.join(root, "remote"))).to be == original + end + + it "preserves manual edits before refreshing an existing PR from the current base" do + prepare + File.write(File.join(repository, "manual.md"), "Keep this for review.\n") + git("add", "manual.md") + git("commit", "--quiet", "-m", "Manual release edits") + git("push", "--quiet", "origin", "releases/v1.0.1") + original = git("rev-parse", "HEAD") + advance_main + expect(prepare(refresh: true, pulls: [pull])).to be == "existing" + remote = File.join(root, "remote") + expect(git("rev-parse", "release-backups/v1.0.1/#{original}", chdir: remote)).to be == original + expect(git("show", "release-backups/v1.0.1/#{original}:manual.md", chdir: remote)).to be == "Keep this for review." + expect(git("show", "releases/v1.0.1:releases.md", chdir: remote)).to be == "## v1.0.1\n\nAn additional change." + expect(git("rev-parse", "releases/v1.0.1^", chdir: remote)).to be == git("rev-parse", "main") + expect(git("rev-parse", "releases/v1.0.1")).to be == original + end + + it "does not replace a concurrent remote update during refresh" do + prepare + original = git("rev-parse", "HEAD") + advance_main + expect do + isolated_project(<<~'RUBY') + require "sus/mock" + project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) + Sus::Mock.new(project).wrap(:system) do |call, *arguments, **options| + if arguments.any?{|argument| argument.start_with?("--force-with-lease=")} + commit = project.readlines("git", "commit-tree", "releases/v1.0.1^{tree}", "-p", "releases/v1.0.1", "-m", "Concurrent edit").join.strip + call.call("git", "push", "--quiet", "origin", "#{commit}:refs/heads/releases/v1.0.1") + end + call.call(*arguments, **options) + end + project.prepare(Bake::Context.load(Dir.pwd), "patch", refresh: true) + RUBY + end.to raise_exception(Bake::Gem::CommandExecutionError) + expect(git("show", "-s", "--format=%s", "releases/v1.0.1", chdir: File.join(root, "remote"))).to be == "Concurrent edit" + expect(git("rev-parse", "release-backups/v1.0.1/#{original}", chdir: File.join(root, "remote"))).to be == original + end + + it "refuses multiple open release PRs" do + expect{prepare(pulls: [pull, pull.merge("headRefName" => "releases/v1.1.0")])}.to raise_exception(RuntimeError, message: be =~ /Multiple release PRs/) + end + + it "refuses to change the bump type of an existing PR" do + expect{prepare(pulls: [pull.merge("headRefName" => "releases/v1.1.0")])}.to raise_exception(RuntimeError, message: be =~ /use its bump type/) + end + + it "rejects a valid release stored under another version's branch name" do + isolated_project('Bake::Context.load(Dir.pwd).call("gem:release:branch:minor")') + git("branch", "--move", "releases/v1.0.1") + git("checkout", "--quiet", "main") + expect{prepare}.to raise_exception(RuntimeError, message: be =~ /requested version 1.0.1/) + end + + it "does not mistake a fork release PR for the repository release branch" do + expect(prepare(pulls: [pull.merge("isCrossRepository" => true)])).to be == "https://github.com/socketry/example/pull/42" + end + end +end