From 02a058029b268ada1172770fbff3113bf9adf43c Mon Sep 17 00:00:00 2001 From: David Elner Date: Tue, 26 May 2026 12:36:45 +0000 Subject: [PATCH 1/4] Changed: Use rubygems-publish environment for publishing --- .github/workflows/publish-gem.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-gem.yaml b/.github/workflows/publish-gem.yaml index a06a645..30ce0b5 100644 --- a/.github/workflows/publish-gem.yaml +++ b/.github/workflows/publish-gem.yaml @@ -36,6 +36,7 @@ jobs: publish: needs: validate runs-on: ubuntu-latest + environment: rubygems-publish permissions: contents: write From e571ccf58fb32f8533301e468a8beb5546f759ca Mon Sep 17 00:00:00 2001 From: David Elner Date: Tue, 26 May 2026 12:56:55 +0000 Subject: [PATCH 2/4] Added: Check to ensure actions are pinned --- .github/workflows/ci.yml | 9 ++++++++- scripts/ensure-pinned-actions.sh | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 scripts/ensure-pinned-actions.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a895f1d..0c677cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,11 +40,18 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ensure-pinned-actions: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - run: bash scripts/ensure-pinned-actions.sh + # Summary job that requires all matrix tests to pass # This is what branch protection will check ci-success: name: CI Success - needs: test + needs: [test, ensure-pinned-actions] runs-on: ubuntu-latest if: always() steps: diff --git a/scripts/ensure-pinned-actions.sh b/scripts/ensure-pinned-actions.sh new file mode 100644 index 0000000..3876912 --- /dev/null +++ b/scripts/ensure-pinned-actions.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify every action reference in .github/workflows/ is pinned to a full commit SHA. +# A pinned ref looks like: uses: owner/action@<40 hex chars> +# Tags (@v4, @main) are rejected — they are mutable and can be hijacked. + +unpinned=$(grep -rn --include="*.yml" --include="*.yaml" -E 'uses:\s+\S+@' .github/ \ + | grep -vE '@[a-f0-9]{40}(\s|$|#)' || true) + +if [ -n "$unpinned" ]; then + echo "ERROR: unpinned action(s) found — use a full commit SHA instead of a tag or branch:" + echo "$unpinned" + exit 1 +fi + +echo "OK: all actions are pinned to commit SHAs." From c0eb7462b3528f7e8b4f4ecc837d21304d7b7f0a Mon Sep 17 00:00:00 2001 From: David Elner Date: Wed, 27 May 2026 16:21:32 +0000 Subject: [PATCH 3/4] Added: Ruby release workflow --- .github/workflows/prerelease.yml | 131 +++++++++++++++++ .github/workflows/publish-gem-prerelease.yaml | 50 ------- .github/workflows/publish-gem.yaml | 61 -------- .github/workflows/release.yml | 134 ++++++++++++++++++ Rakefile | 16 ++- 5 files changed, 275 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/prerelease.yml delete mode 100644 .github/workflows/publish-gem-prerelease.yaml delete mode 100644 .github/workflows/publish-gem.yaml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000..3282f6e --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,131 @@ +# +# 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" + +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.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 + echo "Error: Tag $TAG already exists" + exit 1 + 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 + + 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>\" + }" + + publish: + needs: [validate, notify] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: rubygems-publish + + permissions: + contents: write + id-token: write + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ needs.validate.outputs.sha }} + fetch-depth: 0 + + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.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 + uses: rubygems/configure-rubygems-credentials@a991f145d5e4a60c4b0a3ddb204f557dc1a4f985 # main + + - name: Build and publish prerelease gem + 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: 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: | + 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.\" + }" diff --git a/.github/workflows/publish-gem-prerelease.yaml b/.github/workflows/publish-gem-prerelease.yaml deleted file mode 100644 index e59a93b..0000000 --- a/.github/workflows/publish-gem-prerelease.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# -# This workflow is used to publish the Ruby SDK to RubyGems as a prerelease. -# The version number is automatically modified to append an "alpha" suffix with -# the GitHub run number, so you can push multiple prereleases from any branch. -# - -name: Publish Ruby SDK Prerelease - -on: - workflow_dispatch: - inputs: - ref: - description: "Publish the given Git ref as a prerelease (branch, tag, or commit SHA)" - required: true - type: string - default: "main" - -jobs: - build-and-publish-prerelease: - runs-on: ubuntu-latest - - permissions: - contents: write - id-token: write - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.inputs.ref }} - - - name: Set up Ruby - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 - with: - ruby-version: '3.4' - bundler-cache: true - - - name: Run linter - run: bundle exec rake lint - - # Unfreeze bundler because the prerelease task modifies version.rb, - # which changes the gemspec and would otherwise fail in frozen mode - - name: Unfreeze bundler for prerelease - run: bundle config set --local frozen false - - - name: Configure RubyGems credentials - uses: rubygems/configure-rubygems-credentials@a991f145d5e4a60c4b0a3ddb204f557dc1a4f985 # main - - name: Build and publish prerelease - run: bundle exec rake release:prerelease - env: - GITHUB_RUN_NUMBER: ${{ github.run_number }} diff --git a/.github/workflows/publish-gem.yaml b/.github/workflows/publish-gem.yaml deleted file mode 100644 index 30ce0b5..0000000 --- a/.github/workflows/publish-gem.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# -# This workflow publishes the Ruby SDK to RubyGems when a release tag is pushed. -# It validates the tag, ensures the commit is on main, runs tests, and publishes. -# - -name: Publish Ruby SDK - -on: - push: - tags: - - 'v*.*.*' - -jobs: - validate: - runs-on: ubuntu-latest - outputs: - release_tag: ${{ steps.get-tag.outputs.tag }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - - - name: Get release tag - id: get-tag - run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - - name: Set up Ruby - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 - with: - ruby-version: '3.4' - bundler-cache: true - - - name: Validate release tag - run: bash scripts/validate-release-tag.sh - - publish: - needs: validate - runs-on: ubuntu-latest - environment: rubygems-publish - - permissions: - contents: write - id-token: write - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - - - name: Set up Ruby - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 - with: - ruby-version: '3.4' - bundler-cache: true - - - name: Configure RubyGems credentials - uses: rubygems/configure-rubygems-credentials@a991f145d5e4a60c4b0a3ddb204f557dc1a4f985 # main - - name: Release - run: bundle exec rake release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b605ec4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,134 @@ +# +# 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. +# + +name: Release Ruby SDK + +on: + workflow_dispatch: + +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: main + fetch-depth: 0 + + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + with: + ruby-version: '3.4' + bundler-cache: true + + - name: Read version and validate + id: get-tag + run: | + VERSION=$(ruby -r "./lib/braintrust/version.rb" -e "puts Braintrust::VERSION") + TAG="v${VERSION}" + 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 + 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: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ needs.validate.outputs.sha }} + fetch-depth: 0 + + - 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 }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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" \ + --method POST \ + --field tag_name="$TAG" \ + --jq '.body' 2>/dev/null || echo "_No previous release found — initial release._") + + APPROVE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + + # Write full PR list to job summary — visible on the approval page + echo "## braintrust-sdk-ruby $TAG" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "$NOTES" >> $GITHUB_STEP_SUMMARY + + # Slack message is intentionally brief — full details are one click away + 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>\" + }" + + publish: + needs: [validate, notify] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: rubygems-publish + + permissions: + contents: write + id-token: write + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ needs.validate.outputs.sha }} + fetch-depth: 0 + + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + with: + ruby-version: '3.4' + bundler-cache: true + + - name: Configure RubyGems credentials + uses: rubygems/configure-rubygems-credentials@a991f145d5e4a60c4b0a3ddb204f557dc1a4f985 # main + + - name: Publish gem + 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: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_CHANNEL: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} + TAG: ${{ needs.validate.outputs.release_tag }} + run: | + 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.\" + }" diff --git a/Rakefile b/Rakefile index 64c43e5..906bab7 100644 --- a/Rakefile +++ b/Rakefile @@ -241,9 +241,9 @@ namespace :release do require_relative "lib/braintrust/version" original_version = Braintrust::VERSION - # Generate prerelease version with GitHub run number or timestamp + # 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}.alpha.#{run_number}" + prerelease_version = "#{original_version}.rc.#{run_number}" puts "Original version: #{original_version}" puts "Prerelease version: #{prerelease_version}" @@ -259,10 +259,14 @@ namespace :release do File.write(version_file, modified_content) begin - # Build and publish - Rake::Task["build"].invoke - Rake::Task["release:publish"].invoke - puts "✓ Prerelease #{prerelease_version} published successfully!" + # 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) From 2fc57351927e1df8675dba26bd46bcddd1ad4ea8 Mon Sep 17 00:00:00 2001 From: David Elner Date: Tue, 26 May 2026 22:14:05 +0000 Subject: [PATCH 4/4] Added: Slack notification on security event --- .github/workflows/security-audit.yml | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/security-audit.yml diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..53c633a --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,41 @@ +name: Security Audit + +on: + schedule: + - cron: '0 9 * * 1-5' # Mon-Fri 9am UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + dependabot-alerts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Generate scoped token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.SDK_SECURITY_AUDIT_APP_ID }} + private-key: ${{ secrets.SDK_SECURITY_AUDIT_APP_PRIVATE_KEY }} + repositories: braintrust-sdk-ruby + + - name: Check Dependabot alerts and notify Slack + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_CHANNEL: ${{ vars.SLACK_SDK_SECURITY_CHANNEL }} + run: | + ALERTS=$(gh api "repos/$GITHUB_REPOSITORY/dependabot/alerts?state=open" --jq 'length') + + if [ "$ALERTS" -gt 0 ]; then + SECURITY_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/security/dependabot" + 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>\" + }" + fi