From e84fb697e44d2948520bda1c36635da9aafccb63 Mon Sep 17 00:00:00 2001 From: David Elner Date: Thu, 28 May 2026 14:02:53 +0000 Subject: [PATCH 1/6] Added: CODEOWNERS --- .github/CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..52c4547 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +/lib/ @braintrustdata/sdk-eng +/*.gemspec @braintrustdata/sdk-eng +/.github/ @braintrustdata/sdk-eng +/scripts/ @braintrustdata/sdk-eng From 6db9f8e86efe2433a0e5a62fe6129fa50b9c4f9e Mon Sep 17 00:00:00 2001 From: David Elner Date: Thu, 28 May 2026 14:03:12 +0000 Subject: [PATCH 2/6] Added: Ruby gem attestation --- .github/workflows/release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b605ec4..615ba79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,16 +108,16 @@ jobs: ruby-version: '3.4' bundler-cache: true - - name: Configure RubyGems credentials - uses: rubygems/configure-rubygems-credentials@a991f145d5e4a60c4b0a3ddb204f557dc1a4f985 # main + - name: Create release tag + run: git tag "$GITHUB_REF_NAME" + env: + GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} - - name: Publish gem + - name: Publish gem with attestation + uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} - run: | - git tag "$GITHUB_REF_NAME" - bundle exec rake release - name: Notify Slack on release env: From daf49059fead3cac66127e225990b344c853655f Mon Sep 17 00:00:00 2001 From: David Elner Date: Thu, 28 May 2026 14:16:32 +0000 Subject: [PATCH 3/6] Added: dry_run mode to release workflow --- .github/workflows/ci.yml | 6 ++-- .github/workflows/prerelease.yml | 57 +++++++++++++++++++++-------- .github/workflows/release.yml | 61 +++++++++++++++++++++++--------- 3 files changed, 89 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c677cb..374109e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,12 @@ jobs: steps: # Security: Pin to commit SHA instead of tag to prevent tag hijacking # actions/checkout@v4.3.1 - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Ruby # Security: Pin to commit SHA instead of tag # ruby/setup-ruby@v1.295.0 - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: ${{ matrix.ruby-version }} bundler: 'latest' @@ -44,7 +44,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 2 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - run: bash scripts/ensure-pinned-actions.sh # Summary job that requires all matrix tests to pass diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 3282f6e..54ca4bd 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -15,6 +15,10 @@ on: required: true type: string default: "main" + dry_run: + description: "Dry run — validate, notify, and build without pushing the gem or creating a tag" + type: boolean + default: false jobs: validate: @@ -24,12 +28,12 @@ jobs: release_tag: ${{ steps.get-tag.outputs.tag }} sha: ${{ steps.get-tag.outputs.sha }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref }} fetch-depth: 0 - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: '3.4' bundler-cache: true @@ -42,8 +46,12 @@ jobs: SHA=$(git rev-parse HEAD) if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Error: Tag $TAG already exists" - exit 1 + if [ "${{ inputs.dry_run }}" = "true" ]; then + echo "Warning: Tag $TAG already exists — skipping in dry run" + else + echo "Error: Tag $TAG already exists" + exit 1 + fi fi echo "tag=$TAG" >> $GITHUB_OUTPUT @@ -71,31 +79,35 @@ jobs: echo "**Ref:** ${{ inputs.ref }}" >> $GITHUB_STEP_SUMMARY echo "**SHA:** $SHA" >> $GITHUB_STEP_SUMMARY + DRY_RUN=${{ inputs.dry_run }} + if [ "$DRY_RUN" = "true" ]; then + TEXT=":test_tube: *braintrust-sdk-ruby $TAG* (prerelease) DRY RUN — awaiting approval. <$APPROVE_URL|View & approve>" + else + TEXT=":gem: *braintrust-sdk-ruby $TAG* (prerelease) is awaiting approval. <$APPROVE_URL|View & approve>" + fi + curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json" \ - -d "{ - \"channel\": \"$SLACK_CHANNEL\", - \"text\": \":gem: *braintrust-sdk-ruby $TAG* (prerelease) is awaiting approval. <$APPROVE_URL|View & approve>\" - }" + -d "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" publish: needs: [validate, notify] runs-on: ubuntu-latest timeout-minutes: 15 - environment: rubygems-publish + environment: ${{ inputs.dry_run && 'rubygems-publish-dry-run' || 'rubygems-publish' }} permissions: contents: write id-token: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.validate.outputs.sha }} fetch-depth: 0 - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: '3.4' bundler-cache: true @@ -104,9 +116,11 @@ jobs: run: bundle config set --local frozen false - name: Configure RubyGems credentials + if: ${{ !inputs.dry_run }} uses: rubygems/configure-rubygems-credentials@a991f145d5e4a60c4b0a3ddb204f557dc1a4f985 # main - name: Build and publish prerelease gem + if: ${{ !inputs.dry_run }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_RUN_NUMBER: ${{ github.run_number }} @@ -116,16 +130,29 @@ jobs: git tag "$TAG" gh release create "$TAG" --title "$TAG" --prerelease --generate-notes + - name: Dry run — lint and build only + if: ${{ inputs.dry_run }} + env: + GITHUB_RUN_NUMBER: ${{ github.run_number }} + TAG: ${{ needs.validate.outputs.release_tag }} + run: | + bundle exec rake lint + bundle exec rake build + echo "DRY RUN: would push gem $TAG to RubyGems and create tag" + - 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 }} run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + if [ "${{ inputs.dry_run }}" = "true" ]; then + TEXT=":test_tube: *braintrust-sdk-ruby $TAG* (prerelease) DRY RUN complete — gem built, nothing pushed." + else + TEXT=":white_check_mark: *braintrust-sdk-ruby $TAG* (prerelease) has been published to RubyGems. <$RELEASE_URL|View release>" + fi curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json" \ - -d "{ - \"channel\": \"$SLACK_CHANNEL\", - \"text\": \":white_check_mark: *braintrust-sdk-ruby $TAG* (prerelease) has been published to RubyGems.\" - }" + -d "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 615ba79..fa53afd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,11 @@ name: Release Ruby SDK on: workflow_dispatch: + inputs: + dry_run: + description: "Dry run — validate, notify, and build without pushing the gem or creating a tag" + type: boolean + default: false jobs: validate: @@ -18,12 +23,12 @@ jobs: release_tag: ${{ steps.get-tag.outputs.tag }} sha: ${{ steps.get-tag.outputs.sha }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main fetch-depth: 0 - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: '3.4' bundler-cache: true @@ -36,8 +41,12 @@ jobs: SHA=$(git log -1 --format="%H" -- lib/braintrust/version.rb) if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Error: Tag $TAG already exists — has the version been bumped?" - exit 1 + 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 echo "tag=$TAG" >> $GITHUB_OUTPUT @@ -51,7 +60,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.validate.outputs.sha }} fetch-depth: 0 @@ -69,7 +78,7 @@ jobs: NOTES=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ --method POST \ --field tag_name="$TAG" \ - --jq '.body' 2>/dev/null || echo "_No previous release found — initial release._") + --jq '.body' 2>/dev/null) || NOTES="_Release notes unavailable._" APPROVE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" @@ -79,56 +88,74 @@ jobs: echo "$NOTES" >> $GITHUB_STEP_SUMMARY # Slack message is intentionally brief — full details are one click away + DRY_RUN=${{ inputs.dry_run }} + if [ "$DRY_RUN" = "true" ]; then + TEXT=":test_tube: *braintrust-sdk-ruby $TAG* DRY RUN — awaiting approval. <$APPROVE_URL|View changes & approve>" + else + TEXT=":gem: *braintrust-sdk-ruby $TAG* is awaiting release approval. <$APPROVE_URL|View changes & approve>" + fi + curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json" \ - -d "{ - \"channel\": \"$SLACK_CHANNEL\", - \"text\": \":gem: *braintrust-sdk-ruby $TAG* is awaiting release approval. <$APPROVE_URL|View changes & approve>\" - }" + -d "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" publish: needs: [validate, notify] runs-on: ubuntu-latest timeout-minutes: 15 - environment: rubygems-publish + environment: ${{ inputs.dry_run && 'rubygems-publish-dry-run' || 'rubygems-publish' }} permissions: contents: write id-token: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.validate.outputs.sha }} fetch-depth: 0 - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: '3.4' bundler-cache: true - name: Create release tag + if: ${{ !inputs.dry_run }} run: git tag "$GITHUB_REF_NAME" env: GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} - name: Publish gem with attestation + if: ${{ !inputs.dry_run }} uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} + - name: Dry run — lint and build only + if: ${{ inputs.dry_run }} + env: + GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} + run: | + bundle exec rake lint + bundle exec rake build + echo "DRY RUN: would push gem $GITHUB_REF_NAME to RubyGems and create tag" + - 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 }} run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + if [ "${{ inputs.dry_run }}" = "true" ]; then + TEXT=":test_tube: *braintrust-sdk-ruby $TAG* DRY RUN complete — gem built, nothing pushed." + else + TEXT=":white_check_mark: *braintrust-sdk-ruby $TAG* has been published to RubyGems. <$RELEASE_URL|View release>" + fi curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json" \ - -d "{ - \"channel\": \"$SLACK_CHANNEL\", - \"text\": \":white_check_mark: *braintrust-sdk-ruby $TAG* has been published to RubyGems.\" - }" + -d "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" From ae42391eba46cb7758ccac6443e235eeee8a8c55 Mon Sep 17 00:00:00 2001 From: David Elner Date: Thu, 28 May 2026 15:21:21 +0000 Subject: [PATCH 4/6] Changed: Make release workflow target SHAs explicitly. --- .github/workflows/prerelease.yml | 158 -------------------- .github/workflows/release.yml | 245 +++++++++++++++++++++++++------ scripts/push-release-tag.sh | 91 ------------ scripts/validate-release-tag.sh | 47 +----- 4 files changed, 204 insertions(+), 337 deletions(-) delete mode 100644 .github/workflows/prerelease.yml delete mode 100755 scripts/push-release-tag.sh diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml deleted file mode 100644 index 54ca4bd..0000000 --- a/.github/workflows/prerelease.yml +++ /dev/null @@ -1,158 +0,0 @@ -# -# Prerelease workflow for the Ruby SDK. -# Publishes a release candidate (rc) from any ref to RubyGems. -# Version is auto-generated as {base_version}.rc.{run_number} — no version bump required. -# Follows the same approval gate as stable releases. -# - -name: Prerelease Ruby SDK - -on: - workflow_dispatch: - inputs: - ref: - description: "Branch, tag, or commit SHA to publish as prerelease" - required: true - type: string - default: "main" - dry_run: - description: "Dry run — validate, notify, and build without pushing the gem or creating a tag" - type: boolean - default: false - -jobs: - validate: - runs-on: ubuntu-latest - timeout-minutes: 10 - outputs: - release_tag: ${{ steps.get-tag.outputs.tag }} - sha: ${{ steps.get-tag.outputs.sha }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ inputs.ref }} - fetch-depth: 0 - - - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 - with: - ruby-version: '3.4' - bundler-cache: true - - - name: Generate rc version and validate - id: get-tag - run: | - VERSION=$(ruby -r "./lib/braintrust/version.rb" -e "puts Braintrust::VERSION") - TAG="v${VERSION}.rc.${GITHUB_RUN_NUMBER}" - SHA=$(git rev-parse HEAD) - - 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" - exit 1 - fi - fi - - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "sha=$SHA" >> $GITHUB_OUTPUT - echo "Ready to release $TAG @ $SHA" - - notify: - needs: validate - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - steps: - - name: Post release summary and notify Slack - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - SLACK_CHANNEL: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} - TAG: ${{ needs.validate.outputs.release_tag }} - SHA: ${{ needs.validate.outputs.sha }} - run: | - APPROVE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - - echo "## braintrust-sdk-ruby $TAG (prerelease)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Ref:** ${{ inputs.ref }}" >> $GITHUB_STEP_SUMMARY - echo "**SHA:** $SHA" >> $GITHUB_STEP_SUMMARY - - DRY_RUN=${{ inputs.dry_run }} - if [ "$DRY_RUN" = "true" ]; then - TEXT=":test_tube: *braintrust-sdk-ruby $TAG* (prerelease) DRY RUN — awaiting approval. <$APPROVE_URL|View & approve>" - else - TEXT=":gem: *braintrust-sdk-ruby $TAG* (prerelease) is awaiting approval. <$APPROVE_URL|View & approve>" - fi - - curl -s -X POST "https://slack.com/api/chat.postMessage" \ - -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" - - publish: - needs: [validate, notify] - runs-on: ubuntu-latest - 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: ${{ needs.validate.outputs.sha }} - fetch-depth: 0 - - - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 - with: - ruby-version: '3.4' - bundler-cache: true - - - name: Unfreeze bundler for version modification - run: bundle config set --local frozen false - - - name: Configure RubyGems credentials - if: ${{ !inputs.dry_run }} - uses: rubygems/configure-rubygems-credentials@a991f145d5e4a60c4b0a3ddb204f557dc1a4f985 # main - - - name: Build and publish prerelease gem - if: ${{ !inputs.dry_run }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_RUN_NUMBER: ${{ github.run_number }} - TAG: ${{ needs.validate.outputs.release_tag }} - run: | - bundle exec rake release:prerelease - git tag "$TAG" - gh release create "$TAG" --title "$TAG" --prerelease --generate-notes - - - name: Dry run — lint and build only - if: ${{ inputs.dry_run }} - env: - GITHUB_RUN_NUMBER: ${{ github.run_number }} - TAG: ${{ needs.validate.outputs.release_tag }} - run: | - bundle exec rake lint - bundle exec rake build - echo "DRY RUN: would push gem $TAG to RubyGems and create tag" - - - 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 }} - run: | - RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" - if [ "${{ inputs.dry_run }}" = "true" ]; then - TEXT=":test_tube: *braintrust-sdk-ruby $TAG* (prerelease) DRY RUN complete — gem built, nothing pushed." - else - TEXT=":white_check_mark: *braintrust-sdk-ruby $TAG* (prerelease) has been published to RubyGems. <$RELEASE_URL|View release>" - fi - curl -s -X POST "https://slack.com/api/chat.postMessage" \ - -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa53afd..252128e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ # -# Primary release workflow for the Ruby SDK. -# Triggered manually via GitHub Actions UI (browser session required — not via local git push). -# Reads the version from lib/braintrust/version.rb on main, posts a job summary with the -# list of changes for the reviewer, then waits for human approval before publishing. +# 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 @@ -10,35 +10,59 @@ 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 — validate, notify, and build without pushing the gem or creating a tag" + description: "Dry run: Build without tagging or publishing" type: boolean default: false jobs: validate: + # Generic except where marked LANGUAGE-SPECIFIC runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read outputs: - release_tag: ${{ steps.get-tag.outputs.tag }} - sha: ${{ steps.get-tag.outputs.sha }} + release_tag: ${{ steps.validate-release.outputs.tag }} + commit_message: ${{ steps.validate-release.outputs.commit_message }} + branch: ${{ steps.validate-release.outputs.branch }} + on_main: ${{ steps.validate-release.outputs.on_main }} + prev_tag: ${{ steps.validate-release.outputs.prev_tag }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: main + ref: ${{ inputs.sha }} fetch-depth: 0 + # LANGUAGE-SPECIFIC: replace with your language's setup action - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: '3.4' bundler-cache: true - - name: Read version and validate - id: get-tag + # 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 release + id: validate-release + run: | + VERSION="${{ steps.read-version.outputs.version }}" TAG="v${VERSION}" - SHA=$(git log -1 --format="%H" -- lib/braintrust/version.rb) + 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 @@ -49,59 +73,152 @@ jobs: fi fi + if git merge-base --is-ancestor HEAD origin/main 2>/dev/null; then + ON_MAIN=true + else + ON_MAIN=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 "sha=$SHA" >> $GITHUB_OUTPUT - echo "Ready to release $TAG @ $SHA" + echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + echo "on_main=$ON_MAIN" >> $GITHUB_OUTPUT + echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT + echo "Ready to release $TAG @ ${{ inputs.sha }} ($COMMIT_MSG)" - notify: + prepare: needs: validate runs-on: ubuntu-latest timeout-minutes: 5 permissions: - contents: read + contents: write # required for releases/generate-notes API + outputs: + pr_list: ${{ steps.pr-list.outputs.pr_list }} + notes: ${{ steps.pr-list.outputs.notes }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.validate.outputs.sha }} - fetch-depth: 0 - - - name: Post release summary and notify Slack + - name: Fetch PR list and release notes + id: pr-list env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - SLACK_CHANNEL: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} - TAG: ${{ needs.validate.outputs.release_tag }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.validate.outputs.release_tag }} run: | - PREVIOUS_TAG=$(git describe --tags --abbrev=0 \ - --match='v[0-9]*.[0-9]*.[0-9]*' HEAD^ 2>/dev/null || echo "") - - NOTES=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + PREV_TAG="${{ needs.validate.outputs.prev_tag }}" + BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ --method POST \ --field tag_name="$TAG" \ - --jq '.body' 2>/dev/null) || NOTES="_Release notes unavailable._" + --field target_commitish="${{ inputs.sha }}" \ + ${PREV_TAG:+--field previous_tag_name="$PREV_TAG"} \ + --jq '.body' 2>/dev/null || echo "") - APPROVE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + 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: + needs: [validate, prepare] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Post release summary + env: + TAG: ${{ needs.validate.outputs.release_tag }} + 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_main }}" = "false" ]; then + BRANCH_LABEL="$BRANCH_LABEL ⚠️" + fi - # Write full PR list to job summary — visible on the approval page 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_main }}" = "false" ]; then + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> Release SHA is not on main: Is this a special release? (e.g. beta, backport, etc)" >> $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:** ${{ needs.validate.outputs.commit_message }}" >> $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 - # Slack message is intentionally brief — full details are one click away - DRY_RUN=${{ inputs.dry_run }} - if [ "$DRY_RUN" = "true" ]; then - TEXT=":test_tube: *braintrust-sdk-ruby $TAG* DRY RUN — awaiting approval. <$APPROVE_URL|View changes & approve>" + - name: Notify Slack + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_CHANNEL: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} + TAG: ${{ needs.validate.outputs.release_tag }} + 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_main }}" = "false" ]; then + BRANCH_INFO="> ⚠️ NOT on main: $BRANCH_LINK" else - TEXT=":gem: *braintrust-sdk-ruby $TAG* is awaiting release approval. <$APPROVE_URL|View changes & approve>" + 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 "${{ needs.prepare.outputs.pr_list }}" | 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>" + curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - -H "Content-Type: application/json" \ + -H "Content-Type: application/json; charset=utf-8" \ -d "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" publish: - needs: [validate, notify] + needs: [validate, prepare, notify] runs-on: ubuntu-latest timeout-minutes: 15 environment: ${{ inputs.dry_run && 'rubygems-publish-dry-run' || 'rubygems-publish' }} @@ -113,9 +230,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ needs.validate.outputs.sha }} + ref: ${{ inputs.sha }} fetch-depth: 0 + # LANGUAGE-SPECIFIC: replace with your language's setup action - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: '3.4' @@ -127,21 +245,21 @@ jobs: env: GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} - - name: Publish gem with attestation + # LANGUAGE-SPECIFIC: replace with your language's publish command + - name: Publish package with attestation if: ${{ !inputs.dry_run }} uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} - - name: Dry run — lint and build only + # LANGUAGE-SPECIFIC: replace with your language's build/check command + - name: Dry run — build and check only if: ${{ inputs.dry_run }} - env: - GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} run: | bundle exec rake lint bundle exec rake build - echo "DRY RUN: would push gem $GITHUB_REF_NAME to RubyGems and create tag" + echo "DRY RUN: would push gem ${{ needs.validate.outputs.release_tag }} to RubyGems and create tag" - name: Notify Slack on release env: @@ -150,12 +268,45 @@ jobs: TAG: ${{ needs.validate.outputs.release_tag }} 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 "${{ needs.prepare.outputs.pr_list }}" | 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_main }}" = "false" ]; then + TEXT="$TEXT\n> ⚠️ NOT on main: $BRANCH_LINK" + fi + if [ "${{ inputs.dry_run }}" = "true" ]; then - TEXT=":test_tube: *braintrust-sdk-ruby $TAG* DRY RUN complete — gem built, nothing pushed." + TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" else - TEXT=":white_check_mark: *braintrust-sdk-ruby $TAG* has been published to RubyGems. <$RELEASE_URL|View release>" + TEXT="$TEXT\n${DIFF_PART}<$RELEASE_URL|View release>" fi + + if [ -n "$PR_LIST" ]; then + TEXT="$TEXT\n$PR_LIST" + fi + curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - -H "Content-Type: application/json" \ + -H "Content-Type: application/json; charset=utf-8" \ -d "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" diff --git a/scripts/push-release-tag.sh b/scripts/push-release-tag.sh deleted file mode 100755 index 0c8bab4..0000000 --- a/scripts/push-release-tag.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# Script to push a release tag for the Ruby SDK -# Inspired by py/scripts/push-release-tag.sh - -set -euo pipefail - -# Parse arguments -DRY_RUN=false -while [[ $# -gt 0 ]]; do - case $1 in - --dry-run) - DRY_RUN=true - shift - ;; - *) - echo "Unknown option: $1" - echo "Usage: $0 [--dry-run]" - exit 1 - ;; - esac -done - -# Get the repository root -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -# Fetch latest tags -echo "Fetching latest tags..." -git fetch --tags - -# Get version from version.rb -VERSION=$(ruby -r "./lib/braintrust/version.rb" -e "puts Braintrust::VERSION") -TAG="v${VERSION}" - -# Check if tag already exists -if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Error: Tag $TAG already exists" - exit 1 -fi - -# Get current commit info -COMMIT_SHA=$(git rev-parse HEAD) -COMMIT_SHORT_SHA=$(git rev-parse --short HEAD) -REPO_URL=$(git config --get remote.origin.url | sed 's/\.git$//' | sed 's/git@github.com:/https:\/\/github.com\//') - -# Get the previous tag for comparison -PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - -echo "" -echo "========================================" -echo "Release Information" -echo "========================================" -echo "New version tag: $TAG" -echo "Current commit: $COMMIT_SHA" -echo "Commit URL: ${REPO_URL}/commit/${COMMIT_SHA}" -if [ -n "$PREVIOUS_TAG" ]; then - echo "Previous tag: $PREVIOUS_TAG" - echo "Changelog: ${REPO_URL}/compare/${PREVIOUS_TAG}...${COMMIT_SHORT_SHA}" -fi -echo "========================================" -echo "" - -if [ "$DRY_RUN" = true ]; then - echo "DRY RUN: Would create and push tag $TAG" - echo "Exiting without making changes." - exit 0 -fi - -# Require confirmation -echo "This will create and push tag $TAG to trigger the production release." -echo "Type 'YOLO' to confirm:" -read -r CONFIRMATION - -if [ "$CONFIRMATION" != "YOLO" ]; then - echo "Confirmation failed. Aborting." - exit 1 -fi - -# Create and push the tag -echo "" -echo "Creating tag $TAG..." -git tag "$TAG" - -echo "Pushing tag $TAG..." -git push origin "$TAG" - -echo "" -echo "✓ Tag $TAG has been pushed successfully!" -echo "" -echo "Monitor the release workflow at:" -echo "${REPO_URL}/actions" diff --git a/scripts/validate-release-tag.sh b/scripts/validate-release-tag.sh index c7b8690..0b93363 100755 --- a/scripts/validate-release-tag.sh +++ b/scripts/validate-release-tag.sh @@ -1,62 +1,27 @@ #!/usr/bin/env bash -# Script to validate a release tag for the Ruby SDK -# Ensures the tag matches the version in version.rb and is on the main branch +# Sanity-checks that GITHUB_REF_NAME is set and matches version.rb. +# Called internally by rake release via rake release:validate. +# The release workflow handles all meaningful pre-release checks +# (tag existence, explicit SHA targeting) before this script runs. set -euo pipefail -# Get the repository root REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" -# Get the current tag (should be set in CI environment) RELEASE_TAG="${GITHUB_REF_NAME:-}" if [ -z "$RELEASE_TAG" ]; then echo "Error: GITHUB_REF_NAME is not set" - echo "This script should be run in a GitHub Actions environment" exit 1 fi -echo "Validating release tag: $RELEASE_TAG" - -# Extract version from tag (remove 'v' prefix) TAG_VERSION="${RELEASE_TAG#v}" - -# Get version from version.rb VERSION=$(ruby -r "./lib/braintrust/version.rb" -e "puts Braintrust::VERSION") -echo "Tag version: $TAG_VERSION" -echo "version.rb: $VERSION" - -# Validate version matches if [ "$TAG_VERSION" != "$VERSION" ]; then - echo "" - echo "Error: Tag version does not match version.rb" - echo " Tag: $TAG_VERSION" - echo " version.rb: $VERSION" + echo "Error: Tag version ($TAG_VERSION) does not match version.rb ($VERSION)" exit 1 fi -echo "✓ Version matches" - -# Validate the tag is on the main branch -MAIN_BRANCH="main" -TAG_COMMIT=$(git rev-parse "$RELEASE_TAG") -MAIN_COMMIT=$(git rev-parse "origin/$MAIN_BRANCH") - -# Check if the tag commit is an ancestor of main or is main -if ! git merge-base --is-ancestor "$TAG_COMMIT" "$MAIN_COMMIT" && [ "$TAG_COMMIT" != "$MAIN_COMMIT" ]; then - echo "" - echo "Error: Tag $RELEASE_TAG is not on the $MAIN_BRANCH branch" - echo " Tag commit: $TAG_COMMIT" - echo " Main commit: $MAIN_COMMIT" - exit 1 -fi - -echo "✓ Tag is on the $MAIN_BRANCH branch" - -echo "" -echo "✓ Release tag validation successful" -echo " Tag: $RELEASE_TAG" -echo " Version: $VERSION" -echo " Commit: $TAG_COMMIT" +echo "✓ Tag $RELEASE_TAG matches version.rb ($VERSION)" From 8c98f05f05a969983ce138523535b0cb1372e70b Mon Sep 17 00:00:00 2001 From: David Elner Date: Sun, 31 May 2026 21:12:38 +0000 Subject: [PATCH 5/6] Refactored: rake release to follow convention more closely --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 94 +++++++++++++++++----------- .github/workflows/security-audit.yml | 9 ++- Rakefile | 80 ++++++----------------- scripts/generate-release-notes.sh | 40 ------------ scripts/validate-release-tag.sh | 27 -------- 6 files changed, 82 insertions(+), 172 deletions(-) delete mode 100755 scripts/generate-release-notes.sh delete mode 100755 scripts/validate-release-tag.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 374109e..a8cdcb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ensure-pinned-actions: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 2 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -52,7 +52,7 @@ jobs: ci-success: name: CI Success needs: [test, ensure-pinned-actions] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: always() steps: - name: Check test matrix success diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 252128e..4bb27d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ on: jobs: validate: # Generic except where marked LANGUAGE-SPECIFIC - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -35,7 +35,7 @@ jobs: release_tag: ${{ steps.validate-release.outputs.tag }} commit_message: ${{ steps.validate-release.outputs.commit_message }} branch: ${{ steps.validate-release.outputs.branch }} - on_main: ${{ steps.validate-release.outputs.on_main }} + 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 @@ -44,7 +44,8 @@ jobs: fetch-depth: 0 # LANGUAGE-SPECIFIC: replace with your language's setup action - - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + - name: Set up language runtime + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: '3.4' bundler-cache: true @@ -73,10 +74,10 @@ jobs: fi fi - if git merge-base --is-ancestor HEAD origin/main 2>/dev/null; then - ON_MAIN=true + if [[ "$BRANCH" == "main" ]]; then + ON_RELEASE_BRANCH=true else - ON_MAIN=false + 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 "") @@ -84,13 +85,13 @@ jobs: echo "tag=$TAG" >> $GITHUB_OUTPUT echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_main=$ON_MAIN" >> $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-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: write # required for releases/generate-notes API @@ -126,12 +127,11 @@ jobs: echo "pr_list=$PR_LIST" >> $GITHUB_OUTPUT echo "notes=$(echo "$BODY" | base64 -w 0)" >> $GITHUB_OUTPUT - notify: + notify-pending: needs: [validate, prepare] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 - permissions: - contents: read + permissions: {} steps: - name: Post release summary env: @@ -141,7 +141,7 @@ jobs: 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_main }}" = "false" ]; then + if [ "${{ needs.validate.outputs.on_release_branch }}" = "false" ]; then BRANCH_LABEL="$BRANCH_LABEL ⚠️" fi @@ -154,9 +154,9 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY fi - if [ "${{ needs.validate.outputs.on_main }}" = "false" ]; then + if [ "${{ needs.validate.outputs.on_release_branch }}" = "false" ]; then echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY - echo "> Release SHA is not on main: Is this a special release? (e.g. beta, backport, etc)" >> $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 @@ -183,8 +183,8 @@ jobs: 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_main }}" = "false" ]; then - BRANCH_INFO="> ⚠️ NOT on main: $BRANCH_LINK" + if [ "${{ needs.validate.outputs.on_release_branch }}" = "false" ]; then + BRANCH_INFO="> ⚠️ NOT on release branch: $BRANCH_LINK" else BRANCH_INFO="$BRANCH_LINK" fi @@ -212,14 +212,17 @@ jobs: 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 "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')" publish: - needs: [validate, prepare, notify] - runs-on: ubuntu-latest + needs: [validate, prepare, notify-pending] + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && 'rubygems-publish-dry-run' || 'rubygems-publish' }} @@ -234,32 +237,46 @@ jobs: fetch-depth: 0 # LANGUAGE-SPECIFIC: replace with your language's setup action - - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + - name: Set up language runtime + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: ruby-version: '3.4' bundler-cache: true - - name: Create release tag - if: ${{ !inputs.dry_run }} - run: git tag "$GITHUB_REF_NAME" + # 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_REF_NAME: ${{ needs.validate.outputs.release_tag }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run }} - # LANGUAGE-SPECIFIC: replace with your language's publish command - - name: Publish package with attestation + - name: Create GitHub release if: ${{ !inputs.dry_run }} - uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REF_NAME: ${{ needs.validate.outputs.release_tag }} + 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 }}" - # LANGUAGE-SPECIFIC: replace with your language's build/check command - - name: Dry run — build and check only + - name: Release notes preview if: ${{ inputs.dry_run }} run: | - bundle exec rake lint - bundle exec rake build - echo "DRY RUN: would push gem ${{ needs.validate.outputs.release_tag }} to RubyGems and create tag" + 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: @@ -292,8 +309,8 @@ jobs: 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_main }}" = "false" ]; then - TEXT="$TEXT\n> ⚠️ NOT on main: $BRANCH_LINK" + 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 @@ -306,7 +323,10 @@ jobs: 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 "{\"channel\": \"$SLACK_CHANNEL\", \"text\": \"$TEXT\"}" + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')" diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 53c633a..b753692 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -10,7 +10,7 @@ permissions: jobs: dependabot-alerts: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Generate scoped token @@ -31,11 +31,10 @@ jobs: if [ "$ALERTS" -gt 0 ]; then SECURITY_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/security/dependabot" + TEXT=":warning: *braintrust-sdk-ruby* has $ALERTS open Dependabot alert(s). <$SECURITY_URL|View alerts>" 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 "{ - \"channel\": \"$SLACK_CHANNEL\", - \"text\": \":warning: *braintrust-sdk-ruby* has $ALERTS open Dependabot alert(s). <$SECURITY_URL|View alerts>\" - }" + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')" fi diff --git a/Rakefile b/Rakefile index 906bab7..68f2f04 100644 --- a/Rakefile +++ b/Rakefile @@ -199,11 +199,7 @@ end # Release tasks namespace :release do - task :validate do - sh "bash scripts/validate-release-tag.sh" - end - - task publish: ["release:validate", :lint, :build] do + task publish: [:lint, :build] do gem_files = FileList["braintrust-*.gem"] if gem_files.empty? puts "Error: No gem file found. Build task should have created it." @@ -213,70 +209,32 @@ namespace :release do puts "Found: #{gem_files.join(", ")}" exit 1 end - sh "gem push #{gem_files.first}" - puts "✓ Gem pushed to RubyGems" - end - - task :changelog do - sh "bash scripts/generate-release-notes.sh > changelog.md" - puts "✓ Changelog generated: changelog.md" + if ENV["DRY_RUN"] == "true" + puts "DRY RUN: would push #{gem_files.first} to RubyGems (skipped)" + else + sh "gem push #{gem_files.first}" + puts "✓ Gem pushed to RubyGems" + end end - task github: [:changelog] do + task :push_tag do require_relative "lib/braintrust/version" tag = "v#{Braintrust::VERSION}" - - sh "gh release create #{tag} --title '#{tag}' --notes-file changelog.md" - - # Get the repository URL - repo = `gh repo view --json nameWithOwner -q .nameWithOwner`.strip - release_url = "https://github.com/#{repo}/releases/tag/#{tag}" - - puts "✓ GitHub release created: #{tag}" - puts " #{release_url}" - end - - task :prerelease do - # Get current version - require_relative "lib/braintrust/version" - original_version = Braintrust::VERSION - - # Generate rc version with GitHub run number or timestamp - run_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.to_i.to_s - prerelease_version = "#{original_version}.rc.#{run_number}" - - puts "Original version: #{original_version}" - puts "Prerelease version: #{prerelease_version}" - - # Temporarily modify version.rb - version_file = "lib/braintrust/version.rb" - content = File.read(version_file) - modified_content = content.gsub( - /VERSION = "#{Regexp.escape(original_version)}"/, - "VERSION = \"#{prerelease_version}\"" - ) - - File.write(version_file, modified_content) - - begin - # Lint, build, and push directly — bypasses release:validate which is - # git-tag-centric and not applicable to prereleases. - Rake::Task[:lint].invoke - Rake::Task[:build].invoke - gem_files = FileList["braintrust-*.gem"] - raise "No gem file found after build" if gem_files.empty? - sh "gem push #{gem_files.first}" - puts "✓ Prerelease #{prerelease_version} published to RubyGems" - ensure - # Restore original version - File.write(version_file, content) - puts "Restored original version.rb" + if ENV["DRY_RUN"] == "true" + puts "DRY RUN: would push tag #{tag} to origin (skipped)" + else + sh "git tag #{tag}" + sh "git push origin #{tag}" + puts "✓ Tag #{tag} pushed" end end end -task release: ["release:publish", "release:github"] do - puts "✓ Release completed successfully!" +# Called by rubygems/release-gem in the release workflow. +# Follows Bundler convention: build, push gem, push tag. +# GitHub release creation is handled separately by the workflow. +task release: ["release:publish", "release:push_tag"] do + puts "✓ Release complete" end # Contrib tasks diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh deleted file mode 100755 index 24e62fa..0000000 --- a/scripts/generate-release-notes.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# Script to generate release notes for GitHub Release -# Shows commits between two points in history -# Expected tag format: v0.0.1, v0.0.2, etc. (with 'v' prefix) - -set -euo pipefail - -# Get the repository root -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -# Determine END commit (what to release) -# Priority: arg > GITHUB_REF_NAME > current tag > HEAD -END="${1:-${GITHUB_REF_NAME:-$(git describe --tags --exact-match 2>/dev/null || echo "HEAD")}}" - -# Validate tag format if not HEAD -if [ "$END" != "HEAD" ] && [[ ! "$END" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - echo "Error: Invalid tag format. Expected format: v0.0.1 or HEAD" - echo "Got: $END" - exit 1 -fi - -# Determine START commit (previous release) -if [ "$END" = "HEAD" ]; then - # Local: show unreleased changes since last tag - START=$(git describe --tags --abbrev=0 2>/dev/null || echo "") -else - # CI: show changes since previous tag - START=$(git describe --tags --abbrev=0 "${END}^" 2>/dev/null || echo "") -fi - -# Generate changelog -echo "## Changelog" -echo "" - -if [ -n "$START" ]; then - git log "${START}..${END}" --pretty=format:"- %s (%h)" --no-merges -else - echo "Initial beta version of the Ruby SDK." -fi diff --git a/scripts/validate-release-tag.sh b/scripts/validate-release-tag.sh deleted file mode 100755 index 0b93363..0000000 --- a/scripts/validate-release-tag.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -# Sanity-checks that GITHUB_REF_NAME is set and matches version.rb. -# Called internally by rake release via rake release:validate. -# The release workflow handles all meaningful pre-release checks -# (tag existence, explicit SHA targeting) before this script runs. - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -RELEASE_TAG="${GITHUB_REF_NAME:-}" - -if [ -z "$RELEASE_TAG" ]; then - echo "Error: GITHUB_REF_NAME is not set" - exit 1 -fi - -TAG_VERSION="${RELEASE_TAG#v}" -VERSION=$(ruby -r "./lib/braintrust/version.rb" -e "puts Braintrust::VERSION") - -if [ "$TAG_VERSION" != "$VERSION" ]; then - echo "Error: Tag version ($TAG_VERSION) does not match version.rb ($VERSION)" - exit 1 -fi - -echo "✓ Tag $RELEASE_TAG matches version.rb ($VERSION)" From 5e0ad9fc98813a60c0a312f2a0d17a8e28121d06 Mon Sep 17 00:00:00 2001 From: David Elner Date: Mon, 1 Jun 2026 16:39:40 +0000 Subject: [PATCH 6/6] Fixed: Interpolation and ref checking --- .github/workflows/release.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bb27d1..36ac296 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,13 @@ jobs: 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: | @@ -136,6 +143,7 @@ jobs: - 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 @@ -161,7 +169,7 @@ jobs: fi echo "**SHA:** [${{ inputs.sha }}]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/${{ inputs.sha }})" >> $GITHUB_STEP_SUMMARY - echo "**Commit:** ${{ needs.validate.outputs.commit_message }}" >> $GITHUB_STEP_SUMMARY + echo "**Commit:** $COMMIT_MSG" >> $GITHUB_STEP_SUMMARY echo "**Branch:** $BRANCH_LABEL" >> $GITHUB_STEP_SUMMARY PREV_TAG="${{ needs.validate.outputs.prev_tag }}" @@ -178,6 +186,7 @@ jobs: 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" @@ -196,7 +205,7 @@ jobs: DIFF_PART=" · <$DIFF_URL|${PREV_TAG}...$TAG>" fi - PR_LIST=$(echo "${{ needs.prepare.outputs.pr_list }}" | tr $'\x1f' '\n' | sed '/^$/d') + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') TEXT=":ruby: *braintrust-sdk-ruby $TAG* awaiting approval" @@ -283,6 +292,7 @@ jobs: 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" @@ -293,7 +303,7 @@ jobs: DIFF_PART="<$DIFF_URL|${PREV_TAG}...$TAG> · " fi - PR_LIST=$(echo "${{ needs.prepare.outputs.pr_list }}" | tr $'\x1f' '\n' | sed '/^$/d') + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"