diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 530fb06..108ec0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [ master, main ] +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + jobs: unit: name: Unit Tests & Code Quality diff --git a/.github/workflows/manual-release.yml b/.github/workflows/manual-release.yml new file mode 100644 index 0000000..d03f9b5 --- /dev/null +++ b/.github/workflows/manual-release.yml @@ -0,0 +1,73 @@ +name: Manual Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (example: 6.1.0)" + required: true + type: string + +permissions: + contents: write + +jobs: + manual-release: + name: 🚀 Manual Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: master + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.1.0" + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + + - name: Update version file + env: + VERSION: ${{ github.event.inputs.version }} + run: | + sed -i "s/VERSION = '[^']*'/VERSION = '${VERSION}'/" lib/getstream_ruby/version.rb + + - name: Commit version update + env: + VERSION: ${{ github.event.inputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add lib/getstream_ruby/version.rb + if git diff --cached --quiet; then + echo "No version changes to commit." + else + git commit -m "chore(release): v${VERSION} (manual)" + git push origin HEAD:master + fi + + - name: Create and push tag + env: + VERSION: ${{ github.event.inputs.version }} + run: | + git tag "v${VERSION}" + git push origin "v${VERSION}" + + - name: Build and publish gem + env: + VERSION: ${{ github.event.inputs.version }} + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: | + gem build getstream-ruby.gemspec + gem push getstream-ruby-${VERSION}.gem --key "$GEM_HOST_API_KEY" + + - name: Create GitHub release + uses: ncipollo/release-action@v1 + with: + tag: v${{ github.event.inputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} + body: | + Manual release v${{ github.event.inputs.version }}. diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml deleted file mode 100644 index a140505..0000000 --- a/.github/workflows/prerelease.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Pre-release - -on: - release: - types: [prereleased] - -jobs: - prerelease: - name: 🚀 Pre-release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Resolve version - run: | - TAG="${{ github.event.release.tag_name }}" - VERSION="${TAG#v}" - echo "VERSION=$VERSION" >> $GITHUB_ENV - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 - - - name: Update version file - run: | - sed -i "s/VERSION = '[^']*'/VERSION = '$VERSION'/" lib/getstream_ruby/version.rb - - - name: Build and publish gem - env: - GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} - run: | - gem build getstream-ruby.gemspec - gem push getstream-ruby-$VERSION.gem --key $GEM_HOST_API_KEY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4683b9..17e7be1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,221 +1,107 @@ -name: Release PR +name: Release on: - workflow_dispatch: - inputs: - version_type: - description: 'Version type (used by create-release-pr only)' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - release_notes: - description: 'Release notes (used by create-release-pr only)' - required: false - type: string - publish_directly: - description: 'Skip PR creation and publish the version already in version.rb directly' - required: false - default: false - type: boolean - prerelease: - description: 'Mark the GitHub Release as a pre-release (used when publish_directly is true)' - required: false - default: false - type: boolean - push: - branches: [ main, master ] + pull_request: + types: [closed] + branches: + - main + - master + +concurrency: + group: release-${{ github.event.pull_request.base.ref }} + cancel-in-progress: true + +permissions: + contents: write jobs: - create-release-pr: - name: Create Release PR + release: + name: 🚀 Release + if: github.event.pull_request.merged == true runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish_directly != 'true' - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: - token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 + ref: ${{ github.event.pull_request.base.ref }} - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.1.0' - + ruby-version: "3.1.0" + - name: Install dependencies run: bundle install --jobs 4 --retry 3 - - name: Get current version - id: current_version + - name: Skip when PR is already released + id: already_released run: | - CURRENT_VERSION=$(ruby -r "./lib/getstream_ruby/version.rb" -e "puts GetStreamRuby::VERSION") - echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - echo "Current version: $CURRENT_VERSION" - - - name: Calculate new version - id: new_version - run: | - CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}" - IFS='.' read -r major minor patch <<< "$CURRENT_VERSION" - - case "${{ github.event.inputs.version_type }}" in - "major") - NEW_VERSION="$((major + 1)).0.0" - ;; - "minor") - NEW_VERSION="$major.$((minor + 1)).0" - ;; - "patch") - NEW_VERSION="$major.$minor.$((patch + 1))" - ;; - esac - - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "New version: $NEW_VERSION" - - - name: Create release branch - run: | - NEW_VERSION="${{ steps.new_version.outputs.new_version }}" - RELEASE_BRANCH="release/v$NEW_VERSION" - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git checkout -b "$RELEASE_BRANCH" - echo "RELEASE_BRANCH=$RELEASE_BRANCH" >> $GITHUB_ENV + if git log --oneline --grep="(pr #${{ github.event.pull_request.number }})" -n 1 | grep -q "chore(release):"; then + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo "value=false" >> "$GITHUB_OUTPUT" + fi - - name: Update version files + - name: Determine and apply version bump + id: release_meta + if: steps.already_released.outputs.value != 'true' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} run: | - NEW_VERSION="${{ steps.new_version.outputs.new_version }}" - sed -i "s/VERSION = '[^']*'/VERSION = '$NEW_VERSION'/" "lib/getstream_ruby/version.rb" - - - name: Update CHANGELOG + PR_BODY_FILE=$(mktemp) + printf '%s' "$PR_BODY" > "$PR_BODY_FILE" + ruby scripts/release/bump_version.rb \ + --title "$PR_TITLE" \ + --body-file "$PR_BODY_FILE" \ + --output "$GITHUB_OUTPUT" + + - name: Stop when PR does not require release + if: steps.already_released.outputs.value == 'true' || steps.release_meta.outputs.should_release != 'true' run: | - NEW_VERSION="${{ steps.new_version.outputs.new_version }}" - VERSION_TYPE="${{ github.event.inputs.version_type }}" - RELEASE_NOTES="${{ github.event.inputs.release_notes }}" - - if [ ! -f "CHANGELOG.md" ]; then - echo "# Changelog" > "CHANGELOG.md" - echo "" >> "CHANGELOG.md" - echo "All notable changes to this project will be documented in this file." >> "CHANGELOG.md" - echo "" >> "CHANGELOG.md" - echo "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)," >> "CHANGELOG.md" - echo "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)." >> "CHANGELOG.md" - echo "" >> "CHANGELOG.md" + if [ "${{ steps.already_released.outputs.value }}" = "true" ]; then + echo "PR #${{ github.event.pull_request.number }} is already released; skipping." + exit 0 fi - - TEMP_FILE=$(mktemp) - echo "## [$NEW_VERSION] - $(date +%Y-%m-%d)" >> "$TEMP_FILE" - echo "" >> "$TEMP_FILE" - - if [ -n "$RELEASE_NOTES" ]; then - echo "$RELEASE_NOTES" >> "$TEMP_FILE" - else - echo "### $VERSION_TYPE^2 changes" >> "$TEMP_FILE" - echo "- " >> "$TEMP_FILE" - fi - - echo "" >> "$TEMP_FILE" - cat "CHANGELOG.md" >> "$TEMP_FILE" - mv "$TEMP_FILE" "CHANGELOG.md" + echo "No release type found in PR title; skipping." + exit 0 - - name: Commit and push changes + - name: Commit version files + if: steps.already_released.outputs.value != 'true' && steps.release_meta.outputs.should_release == 'true' run: | - NEW_VERSION="${{ steps.new_version.outputs.new_version }}" - VERSION_TYPE="${{ github.event.inputs.version_type }}" - RELEASE_NOTES="${{ github.event.inputs.release_notes }}" - - git add lib/getstream_ruby/version.rb CHANGELOG.md - git commit -m "Bump version to $NEW_VERSION - - Version type: $VERSION_TYPE - Release notes: ${RELEASE_NOTES:-'Standard $VERSION_TYPE release'}" - - git push -u origin "$RELEASE_BRANCH" --force - - - name: Create Pull Request - uses: actions/github-script@v7 - with: - script: | - const NEW_VERSION = '${{ steps.new_version.outputs.new_version }}'; - const VERSION_TYPE = '${{ github.event.inputs.version_type }}'; - const RELEASE_NOTES = '${{ github.event.inputs.release_notes }}'; - const RELEASE_BRANCH = '${{ env.RELEASE_BRANCH }}'; - - const pr = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Release v${NEW_VERSION}`, - head: RELEASE_BRANCH, - base: 'master', - body: `## Release v${NEW_VERSION} - - **Version Type:** ${VERSION_TYPE} - - **Release Notes:** - ${RELEASE_NOTES || 'Standard ' + VERSION_TYPE + ' release'} - - This PR will automatically create a release when merged. - - ### Changes - - [x] Bumped version to ${NEW_VERSION} - - [x] Updated CHANGELOG.md - - [x] Updated version.rb (gemspec loads version dynamically) - - **⚠️ Only repository admins should merge this PR**` - }); - - release: - name: Release - runs-on: ubuntu-latest - if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_directly == 'true') - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add lib/getstream_ruby/version.rb + if git diff --cached --quiet; then + echo "No version changes to commit." + exit 0 + fi + git commit -m "chore(release): v${{ steps.release_meta.outputs.version }} (pr #${{ github.event.pull_request.number }})" + git push origin "HEAD:${{ github.event.pull_request.base.ref }}" - - name: Get version - id: version + - name: Create release tag + if: steps.already_released.outputs.value != 'true' && steps.release_meta.outputs.should_release == 'true' run: | - VERSION=$(ruby -r "./lib/getstream_ruby/version.rb" -e "puts GetStreamRuby::VERSION") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Run tests - run: make test - - - name: Create GitHub Release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: v${{ steps.version.outputs.version }} - release_name: Release v${{ steps.version.outputs.version }} - body: | - ## Changes in v${{ steps.version.outputs.version }} - - See CHANGELOG.md for details. - draft: false - prerelease: ${{ github.event.inputs.prerelease == 'true' }} - + git tag "${{ steps.release_meta.outputs.tag }}" + git push origin "${{ steps.release_meta.outputs.tag }}" - name: Build and publish gem + if: steps.already_released.outputs.value != 'true' && steps.release_meta.outputs.should_release == 'true' env: GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} run: | gem build getstream-ruby.gemspec - gem push getstream-ruby-${{ steps.version.outputs.version }}.gem --key $GEM_HOST_API_KEY \ No newline at end of file + gem push getstream-ruby-${{ steps.release_meta.outputs.version }}.gem --key "$GEM_HOST_API_KEY" + + - name: Create release on GitHub + if: steps.already_released.outputs.value != 'true' && steps.release_meta.outputs.should_release == 'true' + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.release_meta.outputs.tag }} + token: ${{ secrets.GITHUB_TOKEN }} + body: | + Release v${{ steps.release_meta.outputs.version }} + + - Bump type: `${{ steps.release_meta.outputs.bump }}` + - Previous: `${{ steps.release_meta.outputs.previous_version }}` + - Next: `${{ steps.release_meta.outputs.version }}` \ No newline at end of file diff --git a/README.md b/README.md index 0c8f4de..7a914ad 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,21 @@ To enable integration tests in CI, configure these GitHub repository settings: Bug reports and pull requests are welcome on GitHub at https://github.com/getstream/getstream-ruby. +## Release Process + +Releases use two paths: + +- Default: automatic release when a PR is merged to `main`/`master`. +- Fallback: manual release using `.github/workflows/manual-release.yml` (admin use only). + +Automatic semver bump rules are based on merged PR title/body: + +- `feat:` -> minor +- `fix:` (or `bug:`) -> patch +- `feat!:` or `BREAKING CHANGE` in PR body -> major + +PRs with other prefixes do not trigger a release. + ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/scripts/release/bump_version.rb b/scripts/release/bump_version.rb new file mode 100755 index 0000000..c1234ce --- /dev/null +++ b/scripts/release/bump_version.rb @@ -0,0 +1,107 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'optparse' + +def run_command(command) + `#{command}`.to_s.strip +end + +def find_latest_semver_tag + tags = run_command('git tag --list').split(/\R/) + versions = tags.map(&:strip).map { |tag| tag.sub(/^v/, '') }.grep(/^\d+\.\d+\.\d+$/) + return '0.0.0' if versions.empty? + + versions.max_by { |version| version.split('.').map(&:to_i) } +end + +def determine_bump_type(title, body) + return 'major' if body.match?(/BREAKING[ -]CHANGE/i) + + match = title.strip.match(/^([a-z]+)(\([^)]+\))?(!)?:/i) + return 'none' unless match + + type = match[1].downcase + return 'major' if match[3] == '!' + return 'minor' if type == 'feat' + return 'patch' if %w[fix bug].include?(type) + + 'none' +end + +def increment_version(version, bump) + major, minor, patch = version.split('.').map(&:to_i) + + case bump + when 'major' + "#{major + 1}.0.0" + when 'minor' + "#{major}.#{minor + 1}.0" + when 'patch' + "#{major}.#{minor}.#{patch + 1}" + else + version + end +end + +def update_version_file(path, version) + content = File.read(path) + updated = content.sub(/VERSION = '[^']+'/, "VERSION = '#{version}'") + raise 'Could not update version.rb' if updated == content + + File.write(path, updated) +end + +def write_outputs(output_path, values) + lines = "#{values.map { |k, v| "#{k}=#{v}" }.join("\n")}\n" + if output_path.empty? + print(lines) + return + end + + File.open(output_path, 'a') { |file| file.write(lines) } +end + +options = { + title: '', + body: '', + body_file: '', + output: '', +} + +OptionParser.new do |opts| + + opts.on('--title TITLE') { |value| options[:title] = value } + opts.on('--body BODY') { |value| options[:body] = value } + opts.on('--body-file FILE') { |value| options[:body_file] = value } + opts.on('--output FILE') { |value| options[:output] = value } + +end.parse! + +body = if options[:body_file].empty? + options[:body].to_s + else + File.read(options[:body_file]) + end + +bump = determine_bump_type(options[:title].to_s, body.to_s) +if bump == 'none' + write_outputs(options[:output], { + 'should_release' => 'false', + 'bump' => 'none', + }) + exit(0) +end + +current_version = find_latest_semver_tag +next_version = increment_version(current_version, bump) + +update_version_file('lib/getstream_ruby/version.rb', next_version) + +write_outputs(options[:output], { + 'should_release' => 'true', + 'bump' => bump, + 'previous_version' => current_version, + 'version' => next_version, + 'tag' => "v#{next_version}", + })