Release Ruby SDK #42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # | |
| # Release workflow for the Ruby SDK. | |
| # Triggered manually via GitHub Actions UI — requires an explicit commit SHA. | |
| # Version is read from version.rb at that SHA; a version bump PR is always required before releasing. | |
| # Covers all release types: standard releases, backports, hotfixes, and release candidates. | |
| # | |
| name: Release Ruby SDK | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| _instructions: | |
| description: "⚠️ Before starting: Merge a version bump PR to the target branch. The version is read from the SHA: it cannot be overridden." | |
| type: string | |
| default: "I have merged a version bump PR" | |
| required: false | |
| sha: | |
| description: "Commit SHA (of the version bump) to release" | |
| required: true | |
| type: string | |
| dry_run: | |
| description: "Dry run: Build without tagging or publishing" | |
| type: boolean | |
| default: false | |
| jobs: | |
| validate: | |
| # Generic except where marked LANGUAGE-SPECIFIC | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| outputs: | |
| release_tag: ${{ steps.validate-release.outputs.tag }} | |
| commit_message: ${{ steps.validate-release.outputs.commit_message }} | |
| branch: ${{ steps.validate-release.outputs.branch }} | |
| on_release_branch: ${{ steps.validate-release.outputs.on_release_branch }} | |
| prev_tag: ${{ steps.validate-release.outputs.prev_tag }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ inputs.sha }} | |
| fetch-depth: 0 | |
| # LANGUAGE-SPECIFIC: replace with your language's setup action | |
| - name: Set up language runtime | |
| uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 | |
| with: | |
| ruby-version: '3.4' | |
| bundler-cache: true | |
| # LANGUAGE-SPECIFIC: replace with your language's version read command | |
| - name: Read version | |
| id: read-version | |
| run: | | |
| VERSION=$(ruby -r "./lib/braintrust/version.rb" -e "puts Braintrust::VERSION") | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| - name: Validate SHA format | |
| run: | | |
| if ! echo "${{ inputs.sha }}" | grep -qE '^[0-9a-f]{40}$'; then | |
| echo "Error: sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." | |
| exit 1 | |
| fi | |
| - name: Validate release | |
| id: validate-release | |
| run: | | |
| VERSION="${{ steps.read-version.outputs.version }}" | |
| TAG="v${VERSION}" | |
| COMMIT_MSG=$(git log -1 --format="%s" HEAD) | |
| BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) | |
| if git rev-parse "$TAG" >/dev/null 2>&1; then | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| echo "Warning: Tag $TAG already exists — skipping in dry run" | |
| else | |
| echo "Error: Tag $TAG already exists — has the version been bumped?" | |
| exit 1 | |
| fi | |
| fi | |
| if [[ "$BRANCH" == "main" ]]; then | |
| ON_RELEASE_BRANCH=true | |
| else | |
| ON_RELEASE_BRANCH=false | |
| fi | |
| PREV_TAG=$(git describe --tags --abbrev=0 --match='v[0-9]*.[0-9]*.[0-9]*' HEAD^ 2>/dev/null || echo "") | |
| echo "tag=$TAG" >> $GITHUB_OUTPUT | |
| echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT | |
| echo "branch=$BRANCH" >> $GITHUB_OUTPUT | |
| echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT | |
| echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT | |
| echo "Ready to release $TAG @ ${{ inputs.sha }} ($COMMIT_MSG)" | |
| prepare: | |
| needs: validate | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: write # required for releases/generate-notes API | |
| outputs: | |
| pr_list: ${{ steps.pr-list.outputs.pr_list }} | |
| notes: ${{ steps.pr-list.outputs.notes }} | |
| steps: | |
| - name: Fetch PR list and release notes | |
| id: pr-list | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.validate.outputs.release_tag }} | |
| run: | | |
| PREV_TAG="${{ needs.validate.outputs.prev_tag }}" | |
| BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ | |
| --method POST \ | |
| --field tag_name="$TAG" \ | |
| --field target_commitish="${{ inputs.sha }}" \ | |
| ${PREV_TAG:+--field previous_tag_name="$PREV_TAG"} \ | |
| --jq '.body' 2>/dev/null || echo "") | |
| PR_LIST=$(echo "$BODY" \ | |
| | grep "^\* " \ | |
| | grep -v "made their first contribution" \ | |
| | grep -v "Full Changelog" \ | |
| | head -10 \ | |
| | sed 's|^\* ||' \ | |
| | sed 's| by @[^ ]*||' \ | |
| | sed 's@ in \(https://[^ ]*/pull/\([0-9]*\)\)@ (<\1|#\2>)@' \ | |
| | sed 's/^/• /' \ | |
| | tr '\n' $'\x1f' || echo "") | |
| echo "pr_list=$PR_LIST" >> $GITHUB_OUTPUT | |
| echo "notes=$(echo "$BODY" | base64 -w 0)" >> $GITHUB_OUTPUT | |
| notify-pending: | |
| needs: [validate, prepare] | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| permissions: {} | |
| steps: | |
| - name: Post release summary | |
| env: | |
| TAG: ${{ needs.validate.outputs.release_tag }} | |
| COMMIT_MSG: ${{ needs.validate.outputs.commit_message }} | |
| run: | | |
| NOTES=$(echo "${{ needs.prepare.outputs.notes }}" | base64 -d 2>/dev/null) | |
| if [ -z "$NOTES" ]; then NOTES="_Release notes unavailable._"; fi | |
| BRANCH_LABEL="[${{ needs.validate.outputs.branch }}]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/${{ needs.validate.outputs.branch }})" | |
| if [ "${{ needs.validate.outputs.on_release_branch }}" = "false" ]; then | |
| BRANCH_LABEL="$BRANCH_LABEL ⚠️" | |
| fi | |
| echo "## braintrust-sdk-ruby $TAG" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY | |
| echo "> Dry run: Nothing will be tagged or published." >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| if [ "${{ needs.validate.outputs.on_release_branch }}" = "false" ]; then | |
| echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY | |
| echo "> Release SHA is not on a release branch (expected: main). Is this intentional?" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "**SHA:** [${{ inputs.sha }}]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/${{ inputs.sha }})" >> $GITHUB_STEP_SUMMARY | |
| echo "**Commit:** $COMMIT_MSG" >> $GITHUB_STEP_SUMMARY | |
| echo "**Branch:** $BRANCH_LABEL" >> $GITHUB_STEP_SUMMARY | |
| PREV_TAG="${{ needs.validate.outputs.prev_tag }}" | |
| if [ -n "$PREV_TAG" ]; then | |
| DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_TAG}...${{ inputs.sha }}" | |
| echo "**Diff:** [${PREV_TAG}...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "$NOTES" >> $GITHUB_STEP_SUMMARY | |
| - name: Notify Slack | |
| env: | |
| SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} | |
| SLACK_CHANNEL: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} | |
| TAG: ${{ needs.validate.outputs.release_tag }} | |
| PR_LIST_RAW: ${{ needs.prepare.outputs.pr_list }} | |
| run: | | |
| APPROVE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" | |
| BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/${{ needs.validate.outputs.branch }}" | |
| BRANCH_LINK="<$BRANCH_URL|${{ needs.validate.outputs.branch }}>" | |
| if [ "${{ needs.validate.outputs.on_release_branch }}" = "false" ]; then | |
| BRANCH_INFO="> ⚠️ NOT on release branch: $BRANCH_LINK" | |
| else | |
| BRANCH_INFO="$BRANCH_LINK" | |
| fi | |
| PREV_TAG="${{ needs.validate.outputs.prev_tag }}" | |
| DIFF_PART="" | |
| if [ -n "$PREV_TAG" ]; then | |
| DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_TAG}...${{ inputs.sha }}" | |
| DIFF_PART=" · <$DIFF_URL|${PREV_TAG}...$TAG>" | |
| fi | |
| PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') | |
| TEXT=":ruby: *braintrust-sdk-ruby $TAG* awaiting approval" | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| TEXT="$TEXT\n> :information_source: _Dry run: nothing will be tagged or published._" | |
| fi | |
| TEXT="$TEXT\n${BRANCH_INFO}${DIFF_PART}" | |
| if [ -n "$PR_LIST" ]; then | |
| TEXT="$TEXT\n$PR_LIST" | |
| fi | |
| TEXT="$TEXT\n<$APPROVE_URL|View changes & approve>" | |
| # jq --arg passes strings literally, so \n must be real newlines before encoding | |
| TEXT=$(printf '%b' "$TEXT") | |
| curl -s -X POST "https://slack.com/api/chat.postMessage" \ | |
| -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ | |
| -H "Content-Type: application/json; charset=utf-8" \ | |
| -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ | |
| '{channel: $channel, text: $text}')" | |
| publish: | |
| needs: [validate, prepare, notify-pending] | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| environment: ${{ inputs.dry_run && 'rubygems-publish-dry-run' || 'rubygems-publish' }} | |
| permissions: | |
| contents: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ inputs.sha }} | |
| fetch-depth: 0 | |
| # LANGUAGE-SPECIFIC: replace with your language's setup action | |
| - name: Set up language runtime | |
| uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 | |
| with: | |
| ruby-version: '3.4' | |
| bundler-cache: true | |
| # LANGUAGE-SPECIFIC: replace with your language's publish command. | |
| # Runs `bundle exec rake release` which: lints, builds, pushes gem to | |
| # RubyGems with SLSA attestation, and pushes the git tag to GitHub. | |
| # In dry run, gem push and tag push are skipped via DRY_RUN env var | |
| # in the Rakefile — rubygems/release-gem itself has no dry run mode. | |
| # await-release is disabled in dry run since no gem is pushed. | |
| - name: Publish package with attestation | |
| uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0 | |
| with: | |
| await-release: ${{ inputs.dry_run && 'false' || 'true' }} | |
| setup-trusted-publisher: ${{ inputs.dry_run && 'false' || 'true' }} | |
| attestations: ${{ inputs.dry_run && 'false' || 'true' }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| - name: Create GitHub release | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.validate.outputs.release_tag }} | |
| run: | | |
| echo "${{ needs.prepare.outputs.notes }}" | base64 -d 2>/dev/null > /tmp/release-notes.md | |
| gh release create "$TAG" \ | |
| --title "$TAG" \ | |
| --notes-file /tmp/release-notes.md \ | |
| --target "${{ inputs.sha }}" | |
| - name: Release notes preview | |
| if: ${{ inputs.dry_run }} | |
| run: | | |
| echo "DRY RUN: would create GitHub release ${{ needs.validate.outputs.release_tag }}" | |
| echo "--- Release notes preview ---" | |
| echo "${{ needs.prepare.outputs.notes }}" | base64 -d 2>/dev/null || echo "(unavailable)" | |
| - name: Notify Slack on release | |
| env: | |
| SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} | |
| SLACK_CHANNEL: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} | |
| TAG: ${{ needs.validate.outputs.release_tag }} | |
| PR_LIST_RAW: ${{ needs.prepare.outputs.pr_list }} | |
| run: | | |
| RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" | |
| PREV_TAG="${{ needs.validate.outputs.prev_tag }}" | |
| DIFF_PART="" | |
| if [ -n "$PREV_TAG" ]; then | |
| DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_TAG}...$TAG" | |
| DIFF_PART="<$DIFF_URL|${PREV_TAG}...$TAG> · " | |
| fi | |
| PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') | |
| RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| TEXT=":white_check_mark: *braintrust-sdk-ruby $TAG* complete" | |
| else | |
| TEXT=":white_check_mark: *braintrust-sdk-ruby $TAG* published to RubyGems" | |
| fi | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| TEXT="$TEXT\n> :information_source: _Dry run: gem built, nothing tagged or published._" | |
| fi | |
| BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/${{ needs.validate.outputs.branch }}" | |
| BRANCH_LINK="<$BRANCH_URL|${{ needs.validate.outputs.branch }}>" | |
| if [ "${{ needs.validate.outputs.on_release_branch }}" = "false" ]; then | |
| TEXT="$TEXT\n> ⚠️ NOT on release branch: $BRANCH_LINK" | |
| fi | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" | |
| else | |
| TEXT="$TEXT\n${DIFF_PART}<$RELEASE_URL|View release>" | |
| fi | |
| if [ -n "$PR_LIST" ]; then | |
| TEXT="$TEXT\n$PR_LIST" | |
| fi | |
| # jq --arg passes strings literally, so \n must be real newlines before encoding | |
| TEXT=$(printf '%b' "$TEXT") | |
| curl -s -X POST "https://slack.com/api/chat.postMessage" \ | |
| -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ | |
| -H "Content-Type: application/json; charset=utf-8" \ | |
| -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ | |
| '{channel: $channel, text: $text}')" |