From fea08a49caa2d15e86395e909b1b93eca271fcef Mon Sep 17 00:00:00 2001 From: Mathius Johnson Date: Fri, 5 Sep 2025 12:24:57 -0700 Subject: [PATCH 1/7] Add changeset system documentation --- .changeset/README.md | 86 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .changeset/README.md diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 00000000..7c511633 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,86 @@ +# Changesets + +This directory contains "changeset" files that describe changes made in pull requests. These files are used to automate version bumps and changelog generation. + +## Creating a Changeset + +When you make changes that should be included in the next release, you need to create a changeset file: + +### Method 1: Using rake task (recommended) +```bash +bundle exec rake changeset:add +``` + +This will prompt you for: +- The type of change (patch/minor/major) +- A brief description of your changes + +### Method 2: Manual creation +Create a new markdown file in this directory with a descriptive name (e.g., `fix-workflow-bug.md`): + +```markdown +--- +type: patch +--- + +Fixed bug in workflow execution that caused steps to run out of order +``` + +## Changeset Format + +Each changeset file must have: +1. **Frontmatter** with a `type` field +2. **Description** of the changes + +### Version Bump Types + +- **patch**: Bug fixes, minor improvements, documentation updates (0.0.X) +- **minor**: New features that are backwards compatible (0.X.0) +- **major**: Breaking changes that are not backwards compatible (X.0.0) + +## Examples + +### Patch Release +```markdown +--- +type: patch +--- + +Fixed error handling in workflow executor +``` + +### Minor Release +```markdown +--- +type: minor +--- + +Added support for parallel step execution +``` + +### Major Release +```markdown +--- +type: major +--- + +Renamed workflow configuration keys for better clarity (breaking change) +``` + +## Skipping Changesets + +Some PRs don't require a version bump (e.g., CI changes, documentation updates, tests). To skip the changeset requirement, add the `🤖 Skip Changelog` label to your PR. + +## How It Works + +1. **During PR**: The CI checks that a changeset file exists +2. **After merge to main**: An automated PR is created/updated with all pending changesets +3. **Release PR merge**: Version is bumped, changelog updated, and gem published to RubyGems + +## Multiple Changes in One PR + +If your PR includes multiple distinct changes, you can create multiple changeset files: +- `add-retry-mechanism.md` (minor) +- `fix-timeout-bug.md` (patch) + +The highest severity change determines the version bump (in this case: minor). \ No newline at end of file From e838f87cf8edfcef79ad9b067e4528d51a731c0d Mon Sep 17 00:00:00 2001 From: Mathius Johnson Date: Fri, 5 Sep 2025 12:25:06 -0700 Subject: [PATCH 2/7] Add changeset validation workflow for PRs --- .github/workflows/changeset.yml | 82 +++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/changeset.yml diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml new file mode 100644 index 00000000..a82d1d09 --- /dev/null +++ b/.github/workflows/changeset.yml @@ -0,0 +1,82 @@ +name: Changeset Check + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + check-changeset: + name: Check for changeset + runs-on: ubuntu-latest + # Skip for release PRs and PRs with skip label + if: | + !startsWith(github.head_ref, 'changeset-release/') && + !contains(github.event.pull_request.labels.*.name, '🤖 Skip Changelog') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Check for changeset files + id: changeset-check + run: | + # Get list of changeset files added/modified in this PR + changesets=$(git diff --name-only origin/main...HEAD | grep '^\.changeset/.*\.md$' | grep -v '^\.changeset/README.md$' || true) + + if [ -z "$changesets" ]; then + echo "❌ No changeset found!" + echo "" + echo "This PR requires a changeset file. Please run:" + echo "" + echo " bundle exec rake changeset:add" + echo "" + echo "Or create a file manually in .changeset/ with:" + echo "- Filename: .changeset/your-descriptive-name.md" + echo "- Content format:" + echo " ---" + echo " type: patch|minor|major" + echo " ---" + echo "" + echo " Brief description of your changes" + echo "" + echo "If this PR doesn't require a version bump (e.g., documentation, CI changes)," + echo "add the '🤖 Skip Changelog' label to this PR." + exit 1 + else + echo "✅ Changeset found:" + echo "$changesets" + + # Validate changeset format + for file in $changesets; do + if ! grep -q "^---$" "$file"; then + echo "❌ Invalid changeset format in $file" + echo "Missing frontmatter markers (---)" + exit 1 + fi + + # Extract type from frontmatter + type=$(sed -n '/^---$/,/^---$/p' "$file" | grep "^type:" | sed 's/type: *//') + + if [ -z "$type" ]; then + echo "❌ Invalid changeset format in $file" + echo "Missing 'type' field in frontmatter" + exit 1 + fi + + if ! echo "$type" | grep -qE "^(patch|minor|major)$"; then + echo "❌ Invalid changeset type in $file: $type" + echo "Type must be one of: patch, minor, major" + exit 1 + fi + + echo "✅ Valid changeset: $file (type: $type)" + done + fi \ No newline at end of file From 10eaef12bdd2ebd16ec7d7c082f67111bdd383b7 Mon Sep 17 00:00:00 2001 From: Mathius Johnson Date: Fri, 5 Sep 2025 12:25:15 -0700 Subject: [PATCH 3/7] Add automated release PR workflow --- .github/workflows/release.yml | 287 ++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..19667d82 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,287 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + release: + name: Create or Update Release PR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Check for changesets + id: changesets + run: | + # Find all changeset files + changesets=$(find .changeset -name "*.md" -not -name "README.md" 2>/dev/null || true) + + if [ -z "$changesets" ]; then + echo "No changesets found" + echo "has_changesets=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "has_changesets=true" >> $GITHUB_OUTPUT + echo "Found changesets:" + echo "$changesets" + + - name: Process changesets and determine version + if: steps.changesets.outputs.has_changesets == 'true' + id: version + run: | + # Initialize version bump type + bump_type="patch" + + # Process all changesets + for file in .changeset/*.md; do + [ -f "$file" ] || continue + [ "$file" = ".changeset/README.md" ] && continue + + # Extract type from frontmatter + type=$(sed -n '/^---$/,/^---$/p' "$file" | grep "^type:" | sed 's/type: *//') + + # Determine highest bump type + if [ "$type" = "major" ]; then + bump_type="major" + elif [ "$type" = "minor" ] && [ "$bump_type" != "major" ]; then + bump_type="minor" + fi + done + + echo "Version bump type: $bump_type" + echo "bump_type=$bump_type" >> $GITHUB_OUTPUT + + # Calculate new version + current_version=$(grep VERSION lib/roast/version.rb | sed -E 's/.*"(.*)".*/\1/') + IFS='.' read -r major minor patch <<< "$current_version" + + if [ "$bump_type" = "major" ]; then + new_version="$((major + 1)).0.0" + elif [ "$bump_type" = "minor" ]; then + new_version="$major.$((minor + 1)).0" + else + new_version="$major.$minor.$((patch + 1))" + fi + + echo "Current version: $current_version" + echo "New version: $new_version" + echo "current_version=$current_version" >> $GITHUB_OUTPUT + echo "new_version=$new_version" >> $GITHUB_OUTPUT + + - name: Generate changelog entries + if: steps.changesets.outputs.has_changesets == 'true' + id: changelog + run: | + # Create changelog content + { + echo "## [v${{ steps.version.outputs.new_version }}] - $(date +%Y-%m-%d)" + echo "" + + # Collect changes by type + majors="" + minors="" + patches="" + + for file in .changeset/*.md; do + [ -f "$file" ] || continue + [ "$file" = ".changeset/README.md" ] && continue + + # Extract type and content + type=$(sed -n '/^---$/,/^---$/p' "$file" | grep "^type:" | sed 's/type: *//') + content=$(sed '1,/^---$/d' "$file" | sed '1,/^---$/d' | sed '/^$/d') + + case "$type" in + major) + majors="${majors}- ${content}\n" + ;; + minor) + minors="${minors}- ${content}\n" + ;; + patch) + patches="${patches}- ${content}\n" + ;; + esac + done + + # Output organized changes + if [ -n "$majors" ]; then + echo "### Breaking Changes" + echo -e "$majors" + fi + + if [ -n "$minors" ]; then + echo "### New Features" + echo -e "$minors" + fi + + if [ -n "$patches" ]; then + echo "### Bug Fixes & Improvements" + echo -e "$patches" + fi + } > /tmp/new_changelog_entry.md + + # Save to output + echo "entry<> $GITHUB_OUTPUT + cat /tmp/new_changelog_entry.md >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Check for existing release PR + if: steps.changesets.outputs.has_changesets == 'true' + id: pr-check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Look for existing release PR + pr_number=$(gh pr list --base main --head changeset-release/main --json number --jq '.[0].number // ""') + + if [ -n "$pr_number" ]; then + echo "Found existing release PR: #$pr_number" + echo "pr_exists=true" >> $GITHUB_OUTPUT + echo "pr_number=$pr_number" >> $GITHUB_OUTPUT + else + echo "No existing release PR found" + echo "pr_exists=false" >> $GITHUB_OUTPUT + fi + + - name: Create release branch + if: steps.changesets.outputs.has_changesets == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Create or checkout release branch + if git show-ref --quiet refs/remotes/origin/changeset-release/main; then + git checkout changeset-release/main + git pull origin changeset-release/main + git reset --hard origin/main + else + git checkout -b changeset-release/main + fi + + - name: Update version file + if: steps.changesets.outputs.has_changesets == 'true' + run: | + # Update version.rb + sed -i "s/VERSION = \".*\"/VERSION = \"${{ steps.version.outputs.new_version }}\"/" lib/roast/version.rb + + git add lib/roast/version.rb + + - name: Update CHANGELOG + if: steps.changesets.outputs.has_changesets == 'true' + run: | + # Create new changelog with entry at top + { + cat /tmp/new_changelog_entry.md + echo "" + cat CHANGELOG.md + } > CHANGELOG.tmp + + mv CHANGELOG.tmp CHANGELOG.md + git add CHANGELOG.md + + - name: Remove processed changesets + if: steps.changesets.outputs.has_changesets == 'true' + run: | + # Remove all changeset files except README + find .changeset -name "*.md" -not -name "README.md" -exec git rm {} \; + + - name: Commit and push changes + if: steps.changesets.outputs.has_changesets == 'true' + run: | + git commit -m "Version Packages for Release v${{ steps.version.outputs.new_version }}" + git push origin changeset-release/main --force + + - name: Create or update PR + if: steps.changesets.outputs.has_changesets == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pr_body="This PR was opened by the release workflow. When merged, it will: + - Bump the version from ${{ steps.version.outputs.current_version }} to ${{ steps.version.outputs.new_version }} + - Update the CHANGELOG with the latest changes + - Trigger gem publication to RubyGems + + ## Changes included in this release: + + ${{ steps.changelog.outputs.entry }} + + --- + + **Merge this PR to release v${{ steps.version.outputs.new_version }}**" + + if [ "${{ steps.pr-check.outputs.pr_exists }}" = "true" ]; then + # Update existing PR + gh pr edit ${{ steps.pr-check.outputs.pr_number }} \ + --title "Release v${{ steps.version.outputs.new_version }}" \ + --body "$pr_body" + else + # Create new PR + gh pr create \ + --title "Release v${{ steps.version.outputs.new_version }}" \ + --body "$pr_body" \ + --base main \ + --head changeset-release/main \ + --label "release" + fi + + publish: + name: Publish Gem + runs-on: ubuntu-latest + # Only run when the release PR is merged + if: | + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + contains(github.event.head_commit.message, 'Version Packages for Release') + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Build gem + run: | + gem build roast.gemspec + + - name: Publish to RubyGems + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_AUTH_TOKEN }} + run: | + gem push *.gem + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=$(grep VERSION lib/roast/version.rb | sed -E 's/.*"(.*)".*/\1/') + + # Extract latest changelog entry + changelog=$(sed -n '/^## \[v'$version'\]/,/^## \[v[0-9]/p' CHANGELOG.md | sed '$d') + + gh release create "v$version" \ + --title "v$version" \ + --notes "$changelog" \ + --latest \ No newline at end of file From 27d3a4406e55d231bdbcd689041ff48370cc39a8 Mon Sep 17 00:00:00 2001 From: Mathius Johnson Date: Fri, 5 Sep 2025 12:25:25 -0700 Subject: [PATCH 4/7] Add changeset rake tasks --- lib/roast/tasks/changeset.rake | 178 +++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 lib/roast/tasks/changeset.rake diff --git a/lib/roast/tasks/changeset.rake b/lib/roast/tasks/changeset.rake new file mode 100644 index 00000000..7ee13ae6 --- /dev/null +++ b/lib/roast/tasks/changeset.rake @@ -0,0 +1,178 @@ +# typed: false +# frozen_string_literal: true + +namespace :changeset do + desc "Add a new changeset for version bumping" + task :add do + require "fileutils" + require "cli/ui" + + CLI::UI::Frame.open("Creating Changeset") do + # Get version bump type + type = CLI::UI::Prompt.ask("What type of change is this?") do |handler| + handler.option("patch") { "patch" } + handler.option("minor") { "minor" } + handler.option("major") { "major" } + end + + # Get description + description = CLI::UI::Prompt.ask("Enter a brief description of the changes:") + + # Generate unique filename based on timestamp and random string + timestamp = Time.now.strftime("%Y%m%d%H%M%S") + random_str = SecureRandom.hex(3) + filename = ".changeset/#{timestamp}-#{random_str}.md" + + # Create changeset file + File.write(filename, <<~CONTENT) + --- + type: #{type} + --- + + #{description} + CONTENT + + CLI::UI::Frame.divider("Success") + puts CLI::UI.fmt("{{v}} Created changeset: {{cyan:#{filename}}}") + puts "" + puts "Your changeset has been created. It will be included in the next release." + puts "" + + # Show what version bump this will cause + case type + when "patch" + puts "This will trigger a patch version bump (0.0.X)" + when "minor" + puts "This will trigger a minor version bump (0.X.0)" + when "major" + puts "This will trigger a major version bump (X.0.0)" + end + end + end + + desc "List pending changesets" + task :list do + require "cli/ui" + + changesets = Dir.glob(".changeset/*.md").reject { |f| f.end_with?("README.md") } + + if changesets.empty? + CLI::UI::Frame.open("No Pending Changesets") do + puts "There are no pending changesets." + puts "Run 'bundle exec rake changeset:add' to create one." + end + else + CLI::UI::Frame.open("Pending Changesets (#{changesets.count})") do + changesets.each do |file| + content = File.read(file) + + # Extract type from frontmatter + type = begin + content.match(/type:\s*(\w+)/)[1] + rescue + "unknown" + end + + # Extract description (content after frontmatter) + description = content.split("---", 3).last.strip.lines.first&.strip || "No description" + + # Color code by type + type_display = case type + when "major" + CLI::UI.fmt("{{red:#{type}}}") + when "minor" + CLI::UI.fmt("{{yellow:#{type}}}") + when "patch" + CLI::UI.fmt("{{green:#{type}}}") + else + type + end + + filename = File.basename(file) + puts CLI::UI.fmt("{{bold:#{filename}}} [#{type_display}]: #{description}") + end + + # Determine what version bump will happen + types = changesets.map do |f| + File.read(f).match(/type:\s*(\w+)/)[1] + rescue + nil + end.compact + + bump_type = if types.include?("major") + "major" + elsif types.include?("minor") + "minor" + else + "patch" + end + + CLI::UI::Frame.divider("Next Release") + + current_version = Roast::VERSION + major, minor, patch = current_version.split(".").map(&:to_i) + + new_version = case bump_type + when "major" + "#{major + 1}.0.0" + when "minor" + "#{major}.#{minor + 1}.0" + else + "#{major}.#{minor}.#{patch + 1}" + end + + puts CLI::UI.fmt("Current version: {{cyan:#{current_version}}}") + puts CLI::UI.fmt("Next version will be: {{green:#{new_version}}} ({{bold:#{bump_type}}} bump)") + end + end + end + + desc "Validate all pending changesets" + task :validate do + require "cli/ui" + + changesets = Dir.glob(".changeset/*.md").reject { |f| f.end_with?("README.md") } + + if changesets.empty? + puts CLI::UI.fmt("{{v}} No changesets to validate") + exit 0 + end + + errors = [] + + changesets.each do |file| + content = File.read(file) + filename = File.basename(file) + + # Check for frontmatter + unless content.include?("---") + errors << "#{filename}: Missing frontmatter markers (---)" + next + end + + # Check for type field + unless content.match?(/type:\s*(patch|minor|major)/) + errors << "#{filename}: Invalid or missing 'type' field (must be patch, minor, or major)" + end + + # Check for description + description = content.split("---", 3).last.strip + if description.empty? + errors << "#{filename}: Missing description" + end + end + + if errors.empty? + CLI::UI::Frame.open("Validation Passed", color: :green) do + puts CLI::UI.fmt("{{v}} All #{changesets.count} changesets are valid") + end + else + CLI::UI::Frame.open("Validation Failed", color: :red) do + errors.each do |error| + puts CLI::UI.fmt("{{x}} #{error}") + end + end + exit 1 + end + end +end From 3341ae336b44787a2fcde8a7d33ad4397875c26e Mon Sep 17 00:00:00 2001 From: Mathius Johnson Date: Fri, 5 Sep 2025 12:25:37 -0700 Subject: [PATCH 5/7] Load custom rake tasks from lib/roast/tasks --- Rakefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Rakefile b/Rakefile index a0cd1a29..08275982 100644 --- a/Rakefile +++ b/Rakefile @@ -4,6 +4,9 @@ require "bundler/gem_tasks" require "rubocop/rake_task" require "rake/testtask" +# Load custom rake tasks +Dir.glob("lib/roast/tasks/*.rake").each { |file| load file } + Rake::TestTask.new(:minitest_all) do |t| t.libs << "test" t.libs << "lib" From c440c4aa242ab76e964c558e40225d69ad91712a Mon Sep 17 00:00:00 2001 From: Mathius Johnson Date: Fri, 5 Sep 2025 12:25:45 -0700 Subject: [PATCH 6/7] Document changeset requirements for contributors --- CONTRIBUTING.md | 54 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df481cf3..142e46ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,11 +25,57 @@ * For updating [Roast documentation](https://shopify.github.io/roast/), create it from `gh-pages` branch. (You can skip tests.) * If it makes sense, add tests for your code and/or run a performance benchmark * Make sure all tests pass (`bundle exec rake`) +* **Create a changeset for your changes** (see [Changeset Requirements](#changeset-requirements) below) * Create a pull request +## Changeset Requirements + +All pull requests that modify the gem's functionality require a changeset file. This helps us automate version bumping and changelog generation. + +### Creating a Changeset + +Run the following command and follow the prompts: + +```bash +bundle exec rake changeset:add +``` + +This will ask you to: +1. Select the type of change (patch/minor/major) +2. Provide a brief description of your changes + +### Version Bump Guidelines + +* **patch**: Bug fixes, minor improvements, documentation updates +* **minor**: New features that are backwards compatible +* **major**: Breaking changes that are not backwards compatible + +### Skipping Changesets + +Some changes don't require version bumps: +* CI/CD configuration changes +* Development tooling updates +* Test-only changes +* Documentation typo fixes + +To skip the changeset requirement, add the `🤖 Skip Changelog` label to your PR. + +### Multiple Changes + +If your PR includes multiple distinct changes, you can create multiple changeset files by running `bundle exec rake changeset:add` multiple times. + ## Releasing -* Bump the version in `lib/roast/version.rb` -* Update the `CHANGELOG.md` file -* Open a PR like and merge it to `main` -* Create a new release using the [GitHub UI](https://github.com/Shopify/roast/releases/new) +Releases are now automated! When PRs with changesets are merged to `main`: + +1. A "Release PR" is automatically created/updated that: + * Collects all pending changesets + * Determines the appropriate version bump + * Updates `lib/roast/version.rb` + * Updates `CHANGELOG.md` + +2. When the Release PR is merged: + * The gem is automatically built and published to RubyGems + * A GitHub release is created with the changelog + +No manual version bumping or changelog editing required! From eec9bdb8e3f8f7d91e97057ea6cf906c66af039b Mon Sep 17 00:00:00 2001 From: Mathius Johnson Date: Fri, 5 Sep 2025 12:25:54 -0700 Subject: [PATCH 7/7] Add interactive release management tool --- bin/release | 511 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100755 bin/release diff --git a/bin/release b/bin/release new file mode 100755 index 00000000..84c09b6c --- /dev/null +++ b/bin/release @@ -0,0 +1,511 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "cli/ui" +require "json" +require "open3" +require "time" +require "tempfile" +require "optparse" + +CLI::UI::StdoutRouter.enable + +module Release + REPO = "Shopify/roast" + + class Command + def self.help + "Command help" + end + end + + class Generate < Command + def call(_args, _name) + CLI::UI::Frame.open("Release Generator") do + # Get the last release tag + last_tag = get_last_release_tag + CLI::UI.puts("Last release: #{CLI::UI.fmt("{{cyan:#{last_tag}}}")}") + + # Get merged PRs since last release + prs = get_merged_prs_since(last_tag) + + if prs.empty? + CLI::UI.puts(CLI::UI.fmt("{{yellow:No PRs merged since last release}}")) + return + end + + CLI::UI.puts("Found #{CLI::UI.fmt("{{bold:#{prs.length}}}")} PRs since last release") + CLI::UI.puts("") + + # Review PRs + reviewed_prs = review_prs(prs) + + # Determine version bump + version_bump = determine_version_bump + + # Calculate new version + current_version = get_current_version + new_version = calculate_new_version(current_version, version_bump) + + CLI::UI::Frame.divider("Creating Release PR") + CLI::UI.puts("Current version: #{CLI::UI.fmt("{{cyan:#{current_version}}}")}") + CLI::UI.puts("New version: #{CLI::UI.fmt("{{green:#{new_version}}}")}") + CLI::UI.puts("Version bump: #{CLI::UI.fmt("{{bold:#{version_bump}}}")}") + + # Update version file + update_version(new_version) + + # Update CHANGELOG + update_changelog(new_version, reviewed_prs, version_bump) + + # Create branch and commit + branch_name = "release-v#{new_version}" + create_release_branch(branch_name, new_version) + + # Create PR + pr_url = create_release_pr(branch_name, new_version, reviewed_prs) + + CLI::UI::Frame.divider("Success!") + CLI::UI.puts(CLI::UI.fmt("{{v}} Release PR created: {{underline:#{pr_url}}}")) + CLI::UI.puts("") + CLI::UI.puts("Opening in browser...") + system("open", pr_url) + end + end + + private + + def get_last_release_tag + output, = Open3.capture2("git describe --tags --abbrev=0") + output.strip + rescue + # If no tags exist, use initial commit + output, = Open3.capture2("git rev-list --max-parents=0 HEAD") + output.strip + end + + def get_merged_prs_since(since_ref) + # Get all merge commits since the last release + cmd = [ + "gh", "pr", "list", + "--repo", REPO, + "--state", "merged", + "--limit", "100", + "--json", "number,title,author,mergedAt,url,body", + ] + + output, status = Open3.capture2(*cmd) + unless status.success? + CLI::UI.puts(CLI::UI.fmt("{{red:Failed to fetch PRs}}")) + return [] + end + + all_prs = JSON.parse(output) + + # Get the timestamp of the last release + since_time_output, = Open3.capture2("git show -s --format=%ct #{since_ref}") + since_timestamp = Time.at(since_time_output.strip.to_i) + + # Filter PRs merged after the last release + all_prs.select do |pr| + merged_at = Time.parse(pr["mergedAt"]) + merged_at > since_timestamp + end.sort_by { |pr| Time.parse(pr["mergedAt"]) } + end + + def review_prs(prs) + reviewed = [] + + CLI::UI::Frame.open("Review PRs") do + prs.each_with_index do |pr, index| + CLI::UI::Frame.divider("PR #{index + 1}/#{prs.length}") + + CLI::UI.puts(CLI::UI.fmt("{{bold:##{pr['number']}}}: #{pr['title']}")) + CLI::UI.puts(CLI::UI.fmt("Author: {{cyan:@#{pr['author']['login']}}}")) + CLI::UI.puts(CLI::UI.fmt("URL: {{underline:#{pr['url']}}}")) + CLI::UI.puts("") + + # Show truncated body + if pr["body"] && !pr["body"].empty? + body_preview = pr["body"].lines.first(3).join.strip + CLI::UI.puts("Description: #{body_preview}") + CLI::UI.puts("") + end + + action = CLI::UI::Prompt.ask("What would you like to do?") do |handler| + handler.option("open", &:to_s) + handler.option("include", &:to_s) + handler.option("skip", &:to_s) + end + + case action + when "open" + system("open", pr["url"]) + # Ask again after opening + include = CLI::UI::Prompt.ask("Include this PR?") do |handler| + handler.option("yes") { true } + handler.option("no") { false } + end + reviewed << pr if include + when "include" + reviewed << pr + when "skip" + # Skip this PR + end + end + end + + reviewed + end + + def determine_version_bump + CLI::UI::Frame.open("Version Bump") do + CLI::UI::Prompt.ask("What type of release is this?") do |handler| + handler.option("patch - Bug fixes and minor improvements") { "patch" } + handler.option("minor - New features (backwards compatible)") { "minor" } + handler.option("major - Breaking changes") { "major" } + end + end + end + + def get_current_version + File.read("lib/roast/version.rb").match(/VERSION = "(.+)"/)[1] + end + + def calculate_new_version(current, bump_type) + major, minor, patch = current.split(".").map(&:to_i) + + case bump_type + when "major" + "#{major + 1}.0.0" + when "minor" + "#{major}.#{minor + 1}.0" + when "patch" + "#{major}.#{minor}.#{patch + 1}" + end + end + + def update_version(new_version) + content = File.read("lib/roast/version.rb") + updated = content.gsub(/VERSION = ".+"/, "VERSION = \"#{new_version}\"") + File.write("lib/roast/version.rb", updated) + end + + def update_changelog(version, prs, bump_type) + new_entry = [] + new_entry << "## [v#{version}] - #{Time.now.strftime('%Y-%m-%d')}" + new_entry << "" + + # Group PRs by type (heuristic based on title/bump type) + breaking = [] + features = [] + fixes = [] + + prs.each do |pr| + title = pr["title"].downcase + entry = "* #{pr['title']} (##{pr['number']}) - @#{pr['author']['login']}" + + if bump_type == "major" && prs.length == 1 + breaking << entry + elsif title.include?("fix") || title.include?("bug") + fixes << entry + elsif title.include?("add") || title.include?("feat") || title.include?("new") + features << entry + else + fixes << entry + end + end + + if breaking.any? + new_entry << "### Breaking Changes" + new_entry.concat(breaking) + new_entry << "" + end + + if features.any? + new_entry << "### New Features" + new_entry.concat(features) + new_entry << "" + end + + if fixes.any? + new_entry << "### Bug Fixes & Improvements" + new_entry.concat(fixes) + new_entry << "" + end + + # Read existing changelog + existing = File.exist?("CHANGELOG.md") ? File.read("CHANGELOG.md") : "" + + # Write new changelog + File.write("CHANGELOG.md", new_entry.join("\n") + "\n" + existing) + end + + def create_release_branch(branch_name, version) + system("git checkout -b #{branch_name}") + system("git add lib/roast/version.rb CHANGELOG.md") + system("bundle install") # Update Gemfile.lock if needed + system("git add Gemfile.lock") if File.exist?("Gemfile.lock") + system("git commit -m 'Release v#{version}'") + system("git push -u origin #{branch_name}") + end + + def create_release_pr(branch, version, prs) + pr_body = [] + pr_body << "## Release v#{version}" + pr_body << "" + pr_body << "This PR prepares the release of v#{version}." + pr_body << "" + pr_body << "### Included PRs" + pr_body << "" + + prs.each do |pr| + pr_body << "* ##{pr['number']} - #{pr['title']} (@#{pr['author']['login']})" + end + + pr_body << "" + pr_body << "### Checklist" + pr_body << "" + pr_body << "- [ ] Review included PRs" + pr_body << "- [ ] Verify version bump is appropriate" + pr_body << "- [ ] Check CHANGELOG entries" + pr_body << "- [ ] Tests passing" + pr_body << "" + pr_body << "After merging this PR, run `bin/release ship` to publish the gem." + + # Write body to temp file to handle complex content + Tempfile.create("pr-body") do |f| + f.write(pr_body.join("\n")) + f.flush + + output, = Open3.capture2( + "gh", "pr", "create", + "--repo", REPO, + "--title", "Release v#{version}", + "--body-file", f.path, + "--base", "main", + "--head", branch + ) + + # Extract PR URL from output + output.strip + end + end + + def self.help + "Generate a new release PR" + end + end + + class Ship < Command + def call(_args, _name) + CLI::UI::Frame.open("Release Shipper") do + # Ensure we're on main branch + current_branch = Open3.capture2("git branch --show-current")[0].strip + + unless current_branch == "main" + CLI::UI.puts(CLI::UI.fmt("{{red:Error: Must be on main branch to ship a release}}")) + CLI::UI.puts("Current branch: #{current_branch}") + return + end + + # Ensure working directory is clean + status = Open3.capture2("git status --porcelain")[0] + unless status.empty? + CLI::UI.puts(CLI::UI.fmt("{{red:Error: Working directory has uncommitted changes}}")) + return + end + + # Pull latest + CLI::UI.puts("Pulling latest changes...") + system("git pull") + + # Get current version + version = get_current_version + tag = "v#{version}" + + CLI::UI.puts("Preparing to ship #{CLI::UI.fmt("{{green:#{tag}}}")}") + + # Check if tag already exists + if tag_exists?(tag) + CLI::UI.puts(CLI::UI.fmt("{{red:Error: Tag #{tag} already exists}}")) + return + end + + # Build gem + CLI::UI::Frame.open("Building Gem") do + system("gem build roast.gemspec") + end + + # Confirm before publishing + confirm = CLI::UI::Prompt.ask("Ready to publish #{tag} to RubyGems?") do |handler| + handler.option("yes") { true } + handler.option("no") { false } + end + + return unless confirm + + # Publish to RubyGems + CLI::UI::Frame.open("Publishing to RubyGems") do + gem_file = Dir.glob("*.gem").max_by { |f| File.mtime(f) } + success = system("gem push #{gem_file}") + + unless success + CLI::UI.puts(CLI::UI.fmt("{{red:Failed to publish gem}}")) + return + end + end + + # Create git tag + CLI::UI::Frame.open("Creating Git Tag") do + system("git tag #{tag}") + system("git push origin #{tag}") + end + + # Create GitHub release + CLI::UI::Frame.open("Creating GitHub Release") do + create_github_release(tag) + end + + # Generate Slack announcement + announcement = generate_slack_announcement(version) + + CLI::UI::Frame.divider("Success!") + CLI::UI.puts(CLI::UI.fmt("{{v}} Released v#{version} successfully!")) + CLI::UI.puts("") + CLI::UI.puts("Copy this announcement for #roast:") + CLI::UI.puts("") + CLI::UI.puts(CLI::UI.fmt("{{cyan:#{announcement}}}")) + + # Copy to clipboard if on macOS + if RUBY_PLATFORM.include?("darwin") + IO.popen("pbcopy", "w") { |io| io.write(announcement) } + CLI::UI.puts("") + CLI::UI.puts(CLI::UI.fmt("{{v}} Announcement copied to clipboard")) + end + end + end + + private + + def get_current_version + File.read("lib/roast/version.rb").match(/VERSION = "(.+)"/)[1] + end + + def tag_exists?(tag) + system("git rev-parse #{tag} >/dev/null 2>&1") + end + + def create_github_release(tag) + # Extract changelog for this version + changelog = File.read("CHANGELOG.md") + version_section = extract_version_section(changelog, tag) + + # Create release via gh CLI + Tempfile.create("release-notes") do |f| + f.write(version_section) + f.flush + + output, = Open3.capture2( + "gh", "release", "create", tag, + "--repo", REPO, + "--title", tag, + "--notes-file", f.path, + "--latest" + ) + + CLI::UI.puts("Release URL: #{CLI::UI.fmt("{{underline:#{output.strip}}}")}") + end + end + + def extract_version_section(changelog, tag) + lines = changelog.lines + start_idx = lines.find_index { |l| l.include?("[#{tag}]") } + + return "No changelog entry found" unless start_idx + + # Find next version header or end of file + end_idx = lines[(start_idx + 1)..-1].find_index { |l| l.start_with?("## [v") } + end_idx = end_idx ? start_idx + end_idx : lines.length + + lines[start_idx...end_idx].join.strip + end + + def generate_slack_announcement(version) + changelog = File.read("CHANGELOG.md") + version_section = extract_version_section(changelog, "v#{version}") + + # Parse out the key changes + features = version_section.scan(/^\* (.+)$/).flatten + + announcement = [] + announcement << ":rocket: Roast v#{version} has been released!" + announcement << "" + announcement << "**What's new:**" + + # Take first 3-5 highlights + features.first(5).each do |feature| + announcement << "• #{feature}" + end + + announcement << "" + announcement << "Gem: https://rubygems.org/gems/roast-ai/versions/#{version}" + announcement << "Release notes: https://github.com/#{REPO}/releases/tag/v#{version}" + announcement << "" + announcement << ":gem: `gem install roast-ai` or `bundle update roast-ai`" + + announcement.join("\n") + end + + def self.help + "Ship the current version (publish gem and create release)" + end + end + + class Version < Command + def call(_args, _name) + version = File.read("lib/roast/version.rb").match(/VERSION = "(.+)"/)[1] + CLI::UI.puts(version) + end + + def self.help + "Show current version" + end + end + + def self.run(args) + command = args.shift + + case command + when "generate", "gen", "g" + Generate.new.call(args, command) + when "ship", "s" + Ship.new.call(args, command) + when "version", "v" + Version.new.call(args, command) + when "--help", "-h", nil + show_help + else + CLI::UI.puts(CLI::UI.fmt("{{red:Unknown command: #{command}}}")) + show_help + exit 1 + end + end + + def self.show_help + CLI::UI::Frame.open("Roast Release Tool") do + CLI::UI.puts("Commands:") + CLI::UI.puts("") + CLI::UI.puts(CLI::UI.fmt(" {{bold:generate}} (g) - #{Generate.help}")) + CLI::UI.puts(CLI::UI.fmt(" {{bold:ship}} (s) - #{Ship.help}")) + CLI::UI.puts(CLI::UI.fmt(" {{bold:version}} (v) - #{Version.help}")) + CLI::UI.puts("") + CLI::UI.puts("Usage:") + CLI::UI.puts(" bin/release generate # Review PRs and create release PR") + CLI::UI.puts(" bin/release ship # Publish gem and create GitHub release") + end + end +end + +Release.run(ARGV) \ No newline at end of file