Skip to content

Commit 355ce52

Browse files
Resume and refresh release preparation with preserved branches
1 parent d37efad commit 355ce52

9 files changed

Lines changed: 220 additions & 23 deletions

File tree

‎.github/workflows/release-prepare.yaml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88
required: true
99
type: choice
1010
options: [patch, minor, major]
11+
refresh:
12+
description: Preserve and regenerate an existing release branch
13+
type: boolean
14+
default: false
1115

1216
permissions:
1317
contents: write
@@ -38,10 +42,11 @@ jobs:
3842
env:
3943
GH_TOKEN: ${{ github.token }}
4044
BUMP: ${{ inputs.bump }}
45+
REFRESH: ${{ inputs.refresh }}
4146
run: |
4247
# GitHub.com's shared Actions bot ID; this identity is not for GitHub Enterprise Server.
4348
# https://github.com/actions/checkout#push-a-commit-using-the-built-in-token
4449
git config user.name 'github-actions[bot]'
4550
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
4651
case "$BUMP" in patch|minor|major) ;; *) exit 1 ;; esac
47-
bundle exec bake "gem:github:release:$BUMP"
52+
bundle exec bake "gem:github:release:$BUMP" "refresh=$REFRESH"

‎bake/gem/github/release.rb‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@
66
require_relative "../../../lib/bake/gem/github/publisher"
77

88
# Prepare a patch release and open its PR.
9-
def patch
10-
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "patch")
9+
# @parameter refresh [Boolean] Preserve and regenerate an existing release branch.
10+
def patch(refresh: false)
11+
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "patch", refresh: refresh)
1112
end
1213

1314
# Prepare a minor release and open its PR.
14-
def minor
15-
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "minor")
15+
# @parameter refresh [Boolean] Preserve and regenerate an existing release branch.
16+
def minor(refresh: false)
17+
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "minor", refresh: refresh)
1618
end
1719

1820
# Prepare a major release and open its PR.
19-
def major
20-
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "major")
21+
# @parameter refresh [Boolean] Preserve and regenerate an existing release branch.
22+
def major(refresh: false)
23+
Bake::Gem::GitHub::Project.new(context.root).prepare(context, "major", refresh: refresh)
2124
end
2225

2326
# Resolve and validate a merged PR, emitting a commit output for the publishing job.

‎context/getting-started.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,23 @@ bundle exec bake gem:github:release:patch
6060
gh workflow run release-prepare.yaml -f bump=patch
6161
```
6262

63-
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.
63+
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.
6464

6565
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.
6666

67-
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.
67+
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.
68+
69+
When validation reports stale content, explicitly refresh the same release:
70+
71+
``` bash
72+
git switch main
73+
git pull --ff-only
74+
bundle exec bake gem:github:release:patch refresh=true
75+
# Or dispatch remotely:
76+
gh workflow run release-prepare.yaml -f bump=patch -f refresh=true
77+
```
78+
79+
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.
6880

6981
## Publish and verify
7082

‎guides/getting-started/readme.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,23 @@ bundle exec bake gem:github:release:patch
6060
gh workflow run release-prepare.yaml -f bump=patch
6161
```
6262

63-
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.
63+
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.
6464

6565
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.
6666

67-
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.
67+
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.
68+
69+
When validation reports stale content, explicitly refresh the same release:
70+
71+
``` bash
72+
git switch main
73+
git pull --ff-only
74+
bundle exec bake gem:github:release:patch refresh=true
75+
# Or dispatch remotely:
76+
gh workflow run release-prepare.yaml -f bump=patch -f refresh=true
77+
```
78+
79+
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.
6880

6981
## Publish and verify
7082

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

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,53 @@ def api(path)
3333
end
3434

3535
# Prepare a release through core Bake tasks, then push and create its pull request.
36-
def prepare(context, bump)
36+
def prepare(context, bump, refresh: false)
3737
Release::BUMPS.fetch(bump)
3838
helper = Helper.new(@root)
3939
helper.guard_clean
4040
branch = @config.fetch("branch")
4141
raise "Prepare releases from #{branch}." unless helper.current_branch == branch
4242
system("git", "fetch", "origin", branch, "--tags", chdir: @root)
4343
raise "Local branch differs from origin/#{branch}." unless @release.resolve("HEAD") == @release.resolve("origin/#{branch}")
44-
pulls = JSON.parse(readlines("gh", "pr", "list", "--repo", @repository, "--base", branch, "--state", "open", "--json", "headRefName,url", "--limit", "1000", chdir: @root).join)
45-
if existing = pulls.find{|pr| pr.fetch("headRefName").start_with?("releases/v")}
46-
return existing.fetch("url")
47-
end
44+
pulls = JSON.parse(readlines("gh", "pr", "list", "--repo", @repository, "--base", branch, "--state", "open", "--json", "headRefName,url,isCrossRepository", "--limit", "1000", chdir: @root).join)
45+
pulls = pulls.select{|pr| !pr["isCrossRepository"] && pr.fetch("headRefName").start_with?("releases/v")}
46+
raise "Multiple release PRs are open; select one before preparing another release." if pulls.size > 1
47+
existing = pulls.first
48+
version = Version.new(helper.gemspec.version.segments, nil).increment(Release::BUMPS.fetch(bump)).join
49+
name = "releases/v#{version}"
50+
raise "Existing release PR uses #{existing.fetch('headRefName')}; use its bump type or close it first." if existing && existing.fetch("headRefName") != name
4851
base = @release.resolve("HEAD")
49-
result = context.lookup("gem:release:branch:#{bump}").call
50-
@release.validate(base: base)
51-
system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "--set-upstream", "origin", result.fetch(:branch), chdir: @root)
52-
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"
52+
ref = "refs/heads/#{name}"
53+
remote = readlines("git", "ls-remote", "--heads", "origin", ref, chdir: @root).first
54+
if remote
55+
system("git", "fetch", "origin", ref, chdir: @root)
56+
remote = @release.resolve("FETCH_HEAD")
57+
end
58+
candidate = remote
59+
if !candidate && readlines("git", "branch", "--list", name, chdir: @root).any?
60+
candidate = @release.resolve(ref)
61+
end
62+
if candidate && refresh
63+
# Preserve the complete previous tree before replacing the release branch:
64+
backup = "refs/heads/release-backups/v#{version}/#{candidate}"
65+
push("#{candidate}:#{backup}")
66+
candidate = @release.worktree(base) do |path|
67+
@release.bake(path, "gem:release:version:#{bump}")
68+
readlines("git", "rev-parse", "HEAD", chdir: path).join.strip
69+
end
70+
elsif !candidate
71+
context.lookup("gem:release:branch:#{bump}").call
72+
candidate = @release.resolve("HEAD")
73+
end
74+
metadata = @release.validate(base: base, candidate: candidate)
75+
raise "Release branch does not contain the requested version #{version}." unless metadata.fetch(:version) == version
76+
push("--force-with-lease=#{ref}:#{remote}", "#{candidate}:#{ref}")
77+
return existing.fetch("url") if existing
78+
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"
5379
Tempfile.create("release-pr") do |file|
5480
file.write(body)
5581
file.flush
56-
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
82+
readlines("gh", "pr", "create", "--repo", @repository, "--base", branch, "--head", name, "--title", "Release v#{version}", "--body-file", file.path, chdir: @root).join.strip
5783
end
5884
end
5985

@@ -105,6 +131,12 @@ def apply
105131
end
106132
end
107133
end
134+
135+
private
136+
137+
def push(*arguments)
138+
system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "origin", *arguments, chdir: @root)
139+
end
108140
end
109141
end
110142
end

‎releases.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
- Include the version's release notes in GitHub releases using `bake-releases`.
1414
- Update generated release files in the working tree with `gem:github:setup:update`.
15+
- Resume interrupted release preparation and explicitly refresh stale release PRs while preserving their previous commits.
1516

1617
## v0.0.5
1718

‎templates/release-prepare.yaml.erb‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88
required: true
99
type: choice
1010
options: [patch, minor, major]
11+
refresh:
12+
description: Preserve and regenerate an existing release branch
13+
type: boolean
14+
default: false
1115

1216
permissions:
1317
contents: write
@@ -38,10 +42,11 @@ jobs:
3842
env:
3943
GH_TOKEN: ${{ github.token }}
4044
BUMP: ${{ inputs.bump }}
45+
REFRESH: ${{ inputs.refresh }}
4146
run: |
4247
# GitHub.com's shared Actions bot ID; this identity is not for GitHub Enterprise Server.
4348
# https://github.com/actions/checkout#push-a-commit-using-the-built-in-token
4449
git config user.name 'github-actions[bot]'
4550
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
4651
case "$BUMP" in patch|minor|major) ;; *) exit 1 ;; esac
47-
bundle exec bake "gem:github:release:$BUMP"
52+
bundle exec bake "gem:github:release:$BUMP" "refresh=$REFRESH"

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,18 @@
3535
expect(evidence).to have_keys(name: be == "example", version: be == "1.0.1", commit: be == commit, merged_by: be == "maintainer")
3636
end
3737

38-
it "reports an existing release PR without creating a branch" do
38+
it "validates an existing release PR without creating another" do
39+
isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")')
40+
original = git("rev-parse", "HEAD")
41+
git("checkout", "--quiet", "main")
3942
url = isolated_project(<<~'RUBY')
4043
project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd)
4144
project.pulls = [{"headRefName" => "releases/v1.0.1", "url" => "existing"}]
4245
project.prepare(Bake::Context.load(Dir.pwd), "patch")
4346
RUBY
4447
expect(url).to be == "existing"
4548
expect(git("branch", "--show-current")).to be == "main"
49+
expect(git("rev-parse", "releases/v1.0.1")).to be == original
4650
end
4751

4852
it "refuses preparation from another branch" do

0 commit comments

Comments
 (0)