Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/release-prepare.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
15 changes: 9 additions & 6 deletions bake/gem/github/release.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 42 additions & 10 deletions lib/bake/gem/github/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,53 @@ 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
branch = @config.fetch("branch")
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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion templates/release-prepare.yaml.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
6 changes: 5 additions & 1 deletion test/bake/gem/github/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,18 @@
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"}]
project.prepare(Bake::Context.load(Dir.pwd), "patch")
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
Expand Down
Loading
Loading