Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
61 changes: 61 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Prepare release

on:
workflow_dispatch:
inputs:
bump:
description: Version bump type
required: true
type: choice
options:
- patch
- minor
- major

jobs:
prepare:
name: Bump version and open release PR
runs-on: ubuntu-latest
permissions:
contents: write # push release branch
pull-requests: write # open draft PR
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # required to reach tag history

- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"

- name: Bump version
id: version
run: |
gem install gem-release --no-document
gem bump --version ${{ inputs.bump }} --no-commit
NEW=$(ruby -e "require_relative 'lib/factory_bot/version'; puts FactoryBot::VERSION")
echo "new=$NEW" >> "$GITHUB_OUTPUT"

- name: Update NEWS.md
env:
GH_TOKEN: ${{ github.token }}
run: bundle exec rake "release:update_changelog[${{ steps.version.outputs.new }}]"
Comment on lines +42 to +45
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow runs bundle exec rake ... but never installs the bundle. Unlike .github/workflows/build.yml, there’s no bundle install (or bundler-cache: true), so this step will fail on a fresh runner. Add a dependency install step before invoking bundle exec.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a bundle install step between ruby/setup-ruby and the bundle exec rake call, matching the pattern used in build.yml. Fixed in 5d61d82.


- name: Push branch and open draft PR
env:
GH_TOKEN: ${{ github.token }}
NEW_VERSION: ${{ steps.version.outputs.new }}
run: |
BRANCH="release/v$NEW_VERSION"
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Branch naming here uses release/vX.Y.Z, but the project’s documented convention in RELEASING.md is release-x.x.x. To avoid confusion/automation mismatches, consider aligning the workflow’s branch name with the documented convention (or update the docs as part of this PR).

Suggested change
BRANCH="release/v$NEW_VERSION"
BRANCH="release-$NEW_VERSION"

Copilot uses AI. Check for mistakes.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add lib/factory_bot/version.rb NEWS.md
git commit -m "Release v$NEW_VERSION"
git push origin "$BRANCH"
gh pr create \
--draft \
--base main \
--head "$BRANCH" \
--title "Release v$NEW_VERSION" \
--body "Bumps version to \`$NEW_VERSION\` and updates the changelog with merged PRs since the last release."
2 changes: 2 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ require "standard/rake"

Bundler::GemHelper.install_tasks(name: "factory_bot")

Dir.glob("tasks/*.rake").each { |f| load f }

desc "Default: run all specs and standard"
task default: %w[all_specs standard]

Expand Down
39 changes: 39 additions & 0 deletions tasks/release.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
require "date"
require "json"

namespace :release do
desc "Update NEWS.md with merged PRs since the last release tag. Usage: rake release:update_changelog[VERSION]"
task :update_changelog, [:version] do |_, args|
version = args.fetch(:version) { abort "Usage: rake release:update_changelog[VERSION]" }

# Find the last release tag and when it was made
last_tag = `git tag --sort=-version:refname`.split.grep(/^v?[0-9]/).first
since_date = `git log -1 --format=%as #{last_tag}`.strip

Comment on lines +5 to +12
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last_tag can be nil if the repo has no tags matching ^v?[0-9], which will cause since_date to be derived from HEAD (and can lead to an empty/incorrect changelog range). Consider explicitly aborting with a clear message when no release tag is found, or allow an explicit since/tag argument for the task.

Suggested change
desc "Update NEWS.md with merged PRs since the last release tag. Usage: rake release:update_changelog[VERSION]"
task :update_changelog, [:version] do |_, args|
version = args.fetch(:version) { abort "Usage: rake release:update_changelog[VERSION]" }
# Find the last release tag and when it was made
last_tag = `git tag --sort=-version:refname`.split.grep(/^v?[0-9]/).first
since_date = `git log -1 --format=%as #{last_tag}`.strip
desc "Update NEWS.md with merged PRs since the last release tag. Usage: rake release:update_changelog[VERSION[,TAG[,SINCE]]]"
task :update_changelog, [:version, :tag, :since] do |_, args|
version = args.fetch(:version) { abort "Usage: rake release:update_changelog[VERSION[,TAG[,SINCE]]]" }
# Determine the changelog start date from an explicit date, an explicit tag, or the last release tag
since_date =
if args[:since] && !args[:since].strip.empty?
args[:since].strip
else
last_tag = if args[:tag] && !args[:tag].strip.empty?
args[:tag].strip
else
`git tag --sort=-version:refname`.split.grep(/^v?[0-9]/).first
end
abort "No release tag found matching ^v?[0-9]. Provide TAG or SINCE explicitly: rake release:update_changelog[VERSION[,TAG[,SINCE]]]" unless last_tag
`git log -1 --format=%as #{last_tag}`.strip
end

Copilot uses AI. Check for mistakes.
# Collect merged PRs since that date via the GitHub CLI
raw = `gh pr list --state merged --base main --search "merged:>#{since_date}" --json number,title,author,url --limit 200`
prs = JSON.parse(raw)
pr_entries = prs
.map { |pr| "* #{pr["title"]} by @#{pr.dig("author", "login")} in [##{pr["number"]}](#{pr["url"]})" }
.join("\n")

news = File.read("NEWS.md")
today = Date.today.strftime("%B %-d, %Y")

# Extract and remove the Unreleased section (if present)
unreleased = ""
if news =~ /^## Unreleased\n(.*?)(?=^## )/m
unreleased = $1.strip
news = news.sub(/^## Unreleased\n.*?(?=^## )/m, "")
Comment on lines +25 to +27
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ## Unreleased extraction/removal regex only matches when another ## ... header follows. If ## Unreleased is the last section in NEWS.md, it won’t be merged/removed. Consider extending the regex to also match end-of-file so this works regardless of section ordering.

Suggested change
if news =~ /^## Unreleased\n(.*?)(?=^## )/m
unreleased = $1.strip
news = news.sub(/^## Unreleased\n.*?(?=^## )/m, "")
if news =~ /^## Unreleased\n(.*?)(?=^## |\z)/m
unreleased = $1.strip
news = news.sub(/^## Unreleased\n.*?(?=^## |\z)/m, "")

Copilot uses AI. Check for mistakes.
end

# Combine unreleased entries and merged PR entries
body = [unreleased, pr_entries].reject(&:empty?).join("\n")
new_section = "## #{version} (#{today})\n\n#{body}\n"

news.sub!(/^(# News\n+)/, "\\1#{new_section}\n")
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sub! will return nil if the # News header pattern isn’t found, but the task will still write the file and print success. Consider checking the return value and aborting with a helpful error when the expected header isn’t present, to avoid silently producing no changelog update.

Suggested change
news.sub!(/^(# News\n+)/, "\\1#{new_section}\n")
unless news.sub!(/^(# News\n+)/, "\\1#{new_section}\n")
abort 'Expected NEWS.md to contain a "# News" header so the changelog entry could be inserted.'
end

Copilot uses AI. Check for mistakes.
File.write("NEWS.md", news)

puts "Updated NEWS.md for v#{version}"
end
end