Skip to content

Commit 3045973

Browse files
Resume and refresh release preparation with preserved branches
1 parent 01d6c34 commit 3045973

11 files changed

Lines changed: 222 additions & 25 deletions

File tree

‎.github/releasing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Prepare releases with `bundle exec bake gem:github:release:patch` (or `minor` / `major`), or dispatch `release-prepare.yaml` on the default branch. Core `gem:release:branch:*` tasks only create a local branch and commit; they do not push or publish.
44

5-
Review the entire release diff. Native GitHub rules require the configured reviews and CI, including an up-to-date branch and **Release validation**. Administrators can explicitly bypass the review/check rules. A rebase is sufficient only if regenerated content still matches. If notes are stale, prepare again from the current default branch and preserve any manual edits separately for review.
5+
Review the entire release diff. Native GitHub rules require the configured reviews and CI, including an up-to-date branch and **Release validation**. Administrators can explicitly bypass the review/check rules. A rebase is sufficient only if regenerated content still matches. Retry preparation from the current default branch after an interrupted push or PR creation. If content is stale, use `bundle exec bake gem:github:release:patch refresh=true` (with the original bump type), or dispatch with `refresh=true`. Refresh preserves the previous commit and any manual edits under `release-backups/vVERSION/OLD_SHA`, regenerates, and updates the release branch with an explicit lease. Review preserved edits before removing the backup; a concurrent remote update stops the push.
66

77
Merging a version increase publishes the actual merged commit through `release-publish.yaml`. There is no second release approval. The public `release.cert` stays in Git; the optional private key belongs in the Actions secret `GEM_SIGNING_KEY`, supplied by the `rubygems` environment or an organization secret. RubyGems authentication uses Trusted Publishing.
88

‎.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
@@ -8,6 +8,7 @@
88

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

1213
## v0.0.5
1314

‎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"

‎templates/releasing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Prepare releases with `bundle exec bake gem:github:release:patch` (or `minor` / `major`), or dispatch `release-prepare.yaml` on the default branch. Core `gem:release:branch:*` tasks only create a local branch and commit; they do not push or publish.
44

5-
Review the entire release diff. Native GitHub rules require the configured reviews and CI, including an up-to-date branch and **Release validation**. Administrators can explicitly bypass the review/check rules. A rebase is sufficient only if regenerated content still matches. If notes are stale, prepare again from the current default branch and preserve any manual edits separately for review.
5+
Review the entire release diff. Native GitHub rules require the configured reviews and CI, including an up-to-date branch and **Release validation**. Administrators can explicitly bypass the review/check rules. A rebase is sufficient only if regenerated content still matches. Retry preparation from the current default branch after an interrupted push or PR creation. If content is stale, use `bundle exec bake gem:github:release:patch refresh=true` (with the original bump type), or dispatch with `refresh=true`. Refresh preserves the previous commit and any manual edits under `release-backups/vVERSION/OLD_SHA`, regenerates, and updates the release branch with an explicit lease. Review preserved edits before removing the backup; a concurrent remote update stops the push.
66

77
Merging a version increase publishes the actual merged commit through `release-publish.yaml`. There is no second release approval. The public `release.cert` stays in Git; the optional private key belongs in the Actions secret `GEM_SIGNING_KEY`, supplied by the `rubygems` environment or an organization secret. RubyGems authentication uses Trusted Publishing.
88

‎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)