From 625c7e8270eabcb874d819d3d5d9214295be3c7a Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Thu, 9 Jul 2026 18:03:52 -0400 Subject: [PATCH 01/10] Harden CI: Artifactory OIDC, RubyGems Trusted Publishing, Gemfile.lock - Add Artifactory OIDC composite action for build-time dep resolution - Replace ruby.yml with fork-aware ci.yml (rubocop/test/build, SHA-pinned) - Add deploy.yml: RubyGems Trusted Publishing, no stored API key - Add Gemfile.lock with arm64-darwin + x86_64-linux platforms - Update e2e-tests.yml: remove E2E_TESTS_TOKEN, SHA-pin actions, fork-aware - Add devbox.json, add .claude/ to .gitignore --- .github/actions/artifactory-oidc/action.yml | 51 +++++++++ .github/workflows/ci.yml | 108 ++++++++++++++++++++ .github/workflows/deploy.yml | 62 +++++++++++ .github/workflows/e2e-tests.yml | 22 ++-- .gitignore | 2 +- Gemfile.lock | 104 +++++++++++++++++++ 6 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 .github/actions/artifactory-oidc/action.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 Gemfile.lock diff --git a/.github/actions/artifactory-oidc/action.yml b/.github/actions/artifactory-oidc/action.yml new file mode 100644 index 00000000..eb2c5d1c --- /dev/null +++ b/.github/actions/artifactory-oidc/action.yml @@ -0,0 +1,51 @@ +name: 'Artifactory OIDC Authentication' +description: 'Exchanges a GitHub OIDC token for an Artifactory access token and configures Bundler to use the Artifactory RubyGems mirror.' + +inputs: + artifactory_url: + description: 'The base URL of the Artifactory instance (e.g., https://segment.jfrog.io)' + required: true + +outputs: + token: + description: 'The Artifactory access token' + value: ${{ steps.exchange.outputs.token }} + +runs: + using: 'composite' + steps: + - name: Get OIDC token + id: oidc + shell: bash + run: | + OIDC_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=jfrog-github" | jq -r '.value') + echo "::add-mask::$OIDC_TOKEN" + echo "oidc_token=$OIDC_TOKEN" >> "$GITHUB_OUTPUT" + + - name: Exchange for Artifactory token + id: exchange + shell: bash + env: + ARTIFACTORY_URL: ${{ inputs.artifactory_url }} + run: | + ART_TOKEN=$(curl -sS -X POST \ + "${ARTIFACTORY_URL}/access/api/v1/oidc/token" \ + -H "Content-Type: application/json" \ + -d "{\"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\", \"subject_token\": \"${{ steps.oidc.outputs.oidc_token }}\", \"subject_token_type\": \"urn:ietf:params:oauth:token-type:id_token\", \"provider_name\": \"github\"}" \ + | jq -r '.access_token') + echo "::add-mask::$ART_TOKEN" + echo "token=$ART_TOKEN" >> "$GITHUB_OUTPUT" + + - name: Configure Bundler Artifactory mirror + shell: bash + env: + ART_TOKEN: ${{ steps.exchange.outputs.token }} + ARTIFACTORY_URL: ${{ inputs.artifactory_url }} + run: | + # Extract the hostname from the Artifactory URL + HOST=$(echo "$ARTIFACTORY_URL" | sed 's|https\?://||') + + # Configure Bundler to mirror rubygems.org through Artifactory + bundle config mirror.https://rubygems.org "https://${HOST}/artifactory/api/gems/virtual-gems-thirdparty/" + bundle config "https://${HOST}/artifactory/api/gems/virtual-gems-thirdparty/" "${ART_TOKEN}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f5e86303 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + id-token: write + contents: read + +env: + ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }} + +jobs: + lint: + name: Lint + runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }} + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Authenticate with Artifactory + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: ./.github/actions/artifactory-oidc + with: + artifactory_url: ${{ env.ARTIFACTORY_URL }} + + - name: Install dependencies + run: bundle install + + - name: Run RuboCop + run: bundle exec rubocop + + test: + name: Test (Ruby ${{ matrix.ruby-version }}) + runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }} + strategy: + fail-fast: false + matrix: + ruby-version: ['3.1', '3.2', '3.3'] + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Authenticate with Artifactory + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: ./.github/actions/artifactory-oidc + with: + artifactory_url: ${{ env.ARTIFACTORY_URL }} + + - name: Install dependencies + run: bundle install + + - name: Run tests + run: bundle exec rake + + build: + name: Build + runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }} + needs: [lint, test] + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Authenticate with Artifactory + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: ./.github/actions/artifactory-oidc + with: + artifactory_url: ${{ env.ARTIFACTORY_URL }} + + - name: Install dependencies + run: bundle install + + - name: Build gem + run: gem build analytics-ruby.gemspec + + - name: Verify gem is installable + run: gem install ./analytics-ruby-*.gem + + - name: Upload gem artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: analytics-ruby-gem + path: analytics-ruby-*.gem + if-no-files-found: error diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..123c6a6f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,62 @@ +name: Release + +on: + release: + types: [published] + +permissions: + id-token: write + contents: read + +env: + ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }} + +jobs: + test: + name: Verify + runs-on: ubuntu-x64 + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Authenticate with Artifactory + uses: ./.github/actions/artifactory-oidc + with: + artifactory_url: ${{ env.ARTIFACTORY_URL }} + + - name: Install dependencies + run: bundle install + + - name: Run tests + run: bundle exec rake + + deploy: + name: Publish to RubyGems + runs-on: ubuntu-x64 + needs: [test] + environment: rubygems + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + with: + ruby-version: '3.3' + + - name: Build gem + run: gem build analytics-ruby.gemspec + + - name: Publish to RubyGems (Trusted Publishing) + uses: rubygems/release-gem@612653d273a73bdba6a52d58baaac4a0d1aa8374 # v1 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 0180b0e5..f515c8ee 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -12,29 +12,35 @@ on: required: false default: 'main' +permissions: + id-token: write + contents: read + +env: + ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }} + jobs: e2e-tests: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} + runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }} steps: - name: Checkout SDK - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: path: sdk - name: Checkout sdk-e2e-tests - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: repository: segmentio/sdk-e2e-tests ref: ${{ inputs.e2e_tests_ref || 'main' }} - token: ${{ secrets.E2E_TESTS_TOKEN }} path: sdk-e2e-tests - name: Setup Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 with: - ruby-version: '3.2' + ruby-version: '3.3' - name: Setup Node.js uses: actions/setup-node@v4 @@ -50,7 +56,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: e2e-test-results path: sdk-e2e-tests/test-results/ diff --git a/.gitignore b/.gitignore index ab09e0c5..4d724cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ *.gem -Gemfile.lock .ruby-version coverage/ +.claude/ diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..40c20dcd --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,104 @@ +PATH + remote: . + specs: + analytics-ruby (2.5.0) + +GEM + remote: http://rubygems.org/ + specs: + activesupport (5.2.8.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + ast (2.4.3) + bigdecimal (4.1.2) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.7) + diff-lcs (1.6.2) + docile (1.4.1) + highline (2.0.3) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + json (2.20.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + minitest (5.27.0) + oj (3.17.3) + bigdecimal (>= 3.0) + ostruct (>= 0.2) + ostruct (0.6.3) + parallel (1.28.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.4.2) + regexp_parser (2.12.0) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.88.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-cobertura (3.2.0) + rexml + simplecov (~> 0.19) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + thread_safe (0.3.6) + tzinfo (1.2.11) + thread_safe (~> 0.1) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + arm64-darwin-24 + ruby + x86_64-linux + +DEPENDENCIES + activesupport (~> 5.2.0) + analytics-ruby! + commander (~> 4.4) + oj (~> 3.6) + rake (~> 13.0) + rspec (~> 3.0) + rubocop (~> 1.0) + simplecov + simplecov-cobertura + tzinfo (~> 1.2) + +BUNDLED WITH + 2.7.2 From 3fe50de1b983d03a8c030903458bf87bd6f66c7b Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Mon, 13 Jul 2026 11:32:15 -0400 Subject: [PATCH 02/10] fix(ci): correct Artifactory OIDC provider name and exchange pattern Rewrite composite action to use single-script pattern matching analytics-python: audience=${ARTIFACTORY_URL}, provider_name= github-actions-segmentio, and add JWT claim logging for debugging. --- .github/actions/artifactory-oidc/action.yml | 99 ++++++++++++--------- 1 file changed, 59 insertions(+), 40 deletions(-) diff --git a/.github/actions/artifactory-oidc/action.yml b/.github/actions/artifactory-oidc/action.yml index eb2c5d1c..3bbc99b8 100644 --- a/.github/actions/artifactory-oidc/action.yml +++ b/.github/actions/artifactory-oidc/action.yml @@ -1,51 +1,70 @@ -name: 'Artifactory OIDC Authentication' -description: 'Exchanges a GitHub OIDC token for an Artifactory access token and configures Bundler to use the Artifactory RubyGems mirror.' +name: "Artifactory OIDC Auth" +description: "Exchange GitHub OIDC token for Artifactory access token and configure Bundler" inputs: - artifactory_url: - description: 'The base URL of the Artifactory instance (e.g., https://segment.jfrog.io)' - required: true - -outputs: - token: - description: 'The Artifactory access token' - value: ${{ steps.exchange.outputs.token }} + artifactory-url: + description: "JFrog platform base URL. Falls back to ARTIFACTORY_URL env var." + required: false + default: "" + oidc-provider-name: + description: "OIDC provider name configured in Artifactory" + required: false + default: "github-actions-segmentio" + gems-repo: + description: "Artifactory virtual RubyGems repository name" + required: false + default: "virtual-gems-thirdparty" runs: - using: 'composite' + using: "composite" steps: - - name: Get OIDC token - id: oidc - shell: bash - run: | - OIDC_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=jfrog-github" | jq -r '.value') - echo "::add-mask::$OIDC_TOKEN" - echo "oidc_token=$OIDC_TOKEN" >> "$GITHUB_OUTPUT" - - - name: Exchange for Artifactory token - id: exchange + - name: Exchange GitHub OIDC token for Artifactory token shell: bash env: - ARTIFACTORY_URL: ${{ inputs.artifactory_url }} + INPUT_ARTIFACTORY_URL: ${{ inputs.artifactory-url }} + OIDC_PROVIDER_NAME: ${{ inputs.oidc-provider-name }} + GEMS_REPO: ${{ inputs.gems-repo }} run: | - ART_TOKEN=$(curl -sS -X POST \ - "${ARTIFACTORY_URL}/access/api/v1/oidc/token" \ - -H "Content-Type: application/json" \ - -d "{\"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\", \"subject_token\": \"${{ steps.oidc.outputs.oidc_token }}\", \"subject_token_type\": \"urn:ietf:params:oauth:token-type:id_token\", \"provider_name\": \"github\"}" \ - | jq -r '.access_token') + set -euo pipefail + ARTIFACTORY_URL="${INPUT_ARTIFACTORY_URL:-${ARTIFACTORY_URL:-}}" + if [ -z "${ARTIFACTORY_URL}" ]; then + echo "::error::ARTIFACTORY_URL is not set (pass as input or set as env var)"; exit 1 + fi + + OIDC_JWT=$(curl -sS \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${ARTIFACTORY_URL}" \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" | jq -r '.value') + + if [ -z "$OIDC_JWT" ] || [ "$OIDC_JWT" = "null" ]; then + echo "::error::Failed to obtain GitHub OIDC token"; exit 1 + fi + + decode_seg() { local s="${1}"; local m=$(( ${#s} % 4 )); [ $m -ne 0 ] && s="${s}$(printf '=%.0s' $(seq 1 $((4-m))))"; echo "$s" | tr '_-' '/+' | base64 -d 2>/dev/null; } + PAYLOAD=$(decode_seg "$(echo "$OIDC_JWT" | cut -d. -f2)") + echo "OIDC token claims:" + echo " sub = $(echo "$PAYLOAD" | jq -r '.sub')" + echo " aud = $(echo "$PAYLOAD" | jq -r '.aud')" + echo " iss = $(echo "$PAYLOAD" | jq -r '.iss')" + + RESP=$(curl -sS "${ARTIFACTORY_URL}/access/api/v1/oidc/token" \ + -H 'Content-Type: application/json' \ + -d "{\"grant_type\":\"urn:ietf:params:oauth:grant-type:token-exchange\", + \"subject_token_type\":\"urn:ietf:params:oauth:token-type:id_token\", + \"subject_token\":\"${OIDC_JWT}\", + \"provider_name\":\"${OIDC_PROVIDER_NAME}\"}") + + ART_TOKEN=$(echo "$RESP" | jq -r '.access_token // empty') + + if [ -z "$ART_TOKEN" ]; then + echo "::error::OIDC token exchange failed." + echo "$RESP" | jq 'if .access_token then .access_token="" else . end' 2>/dev/null || echo "$RESP" + exit 1 + fi echo "::add-mask::$ART_TOKEN" - echo "token=$ART_TOKEN" >> "$GITHUB_OUTPUT" - - name: Configure Bundler Artifactory mirror - shell: bash - env: - ART_TOKEN: ${{ steps.exchange.outputs.token }} - ARTIFACTORY_URL: ${{ inputs.artifactory_url }} - run: | - # Extract the hostname from the Artifactory URL - HOST=$(echo "$ARTIFACTORY_URL" | sed 's|https\?://||') + HOST=$(echo "${ARTIFACTORY_URL}" | sed -E 's#^https?://##') + MIRROR_URL="https://${HOST}/artifactory/api/gems/${GEMS_REPO}/" - # Configure Bundler to mirror rubygems.org through Artifactory - bundle config mirror.https://rubygems.org "https://${HOST}/artifactory/api/gems/virtual-gems-thirdparty/" - bundle config "https://${HOST}/artifactory/api/gems/virtual-gems-thirdparty/" "${ART_TOKEN}" + bundle config mirror.https://rubygems.org "${MIRROR_URL}" + bundle config "https://${HOST}/artifactory/api/gems/${GEMS_REPO}/" ":${ART_TOKEN}" + echo "Configured Bundler to resolve through Artifactory (${GEMS_REPO})" From 737ada035f092f52e4f1ef2f213366a67a64314a Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Wed, 22 Jul 2026 20:07:46 -0400 Subject: [PATCH 03/10] fix(ci): harden OIDC error redaction to cover all token fields --- .github/actions/artifactory-oidc/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/artifactory-oidc/action.yml b/.github/actions/artifactory-oidc/action.yml index 3bbc99b8..c6b7ffcd 100644 --- a/.github/actions/artifactory-oidc/action.yml +++ b/.github/actions/artifactory-oidc/action.yml @@ -57,7 +57,8 @@ runs: if [ -z "$ART_TOKEN" ]; then echo "::error::OIDC token exchange failed." - echo "$RESP" | jq 'if .access_token then .access_token="" else . end' 2>/dev/null || echo "$RESP" + echo "$RESP" | jq 'walk(if type == "object" then with_entries(if (.key | test("token"; "i")) then .value = "" else . end) else . end)' 2>/dev/null \ + || echo "::error::(response withheld — not valid JSON)" exit 1 fi echo "::add-mask::$ART_TOKEN" From 8cbbd29264717daee839831d5958c88e61bf6d3a Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Fri, 25 Sep 2026 21:03:26 -0400 Subject: [PATCH 04/10] fix(ci): correct third-party action pins to real upstream commits The pinned SHAs for setup-ruby/setup-php/codecov/release-gem did not exist upstream, so the org's sha_pinning_required check could never resolve them. Repinned to the commits the v1/v2/v5 tags actually point at. --- .github/workflows/ci.yml | 6 +++--- .github/workflows/deploy.yml | 6 +++--- .github/workflows/e2e-tests.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5e86303..245b591b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 with: ruby-version: '3.3' bundler-cache: true @@ -53,7 +53,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true @@ -80,7 +80,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 with: ruby-version: '3.3' bundler-cache: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 123c6a6f..6aabe789 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 with: ruby-version: '3.3' bundler-cache: true @@ -51,7 +51,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 with: ruby-version: '3.3' @@ -59,4 +59,4 @@ jobs: run: gem build analytics-ruby.gemspec - name: Publish to RubyGems (Trusted Publishing) - uses: rubygems/release-gem@612653d273a73bdba6a52d58baaac4a0d1aa8374 # v1 + uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f515c8ee..5f86af57 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -38,7 +38,7 @@ jobs: path: sdk-e2e-tests - name: Setup Ruby - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e01a7b8b7f2007 # v1 + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 with: ruby-version: '3.3' From f109cefea66bfac0a211a75cc269fdbfb239f687 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Fri, 25 Sep 2026 21:33:25 -0400 Subject: [PATCH 05/10] fix(ci): select Ruby from the runner tool cache instead of ruby/setup-ruby The org Actions policy admits GitHub-owned actions only, so ruby/setup-ruby could never resolve and every workflow referencing it failed at startup with no check reported. The runner image already caches 3.2/3.3/3.4, so a local composite action can select one without downloading anything. Gem install also moves after the Artifactory OIDC step. setup-ruby's bundler-cache option ran bundle install before it, resolving from rubygems.org and bypassing the curated mirror. Matrix is now 3.2/3.3/3.4: 3.1 is absent from the image and left upstream support in March 2025. deploy.yml still references rubygems/release-gem, which needs an allow-list entry before a release can run. --- .github/actions/setup-ruby/action.yml | 67 +++++++++++++++++++++++++++ .github/workflows/ci.yml | 47 +++++++++++++++---- .github/workflows/deploy.yml | 5 +- .github/workflows/e2e-tests.yml | 4 +- 4 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 .github/actions/setup-ruby/action.yml diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml new file mode 100644 index 00000000..2689b0f1 --- /dev/null +++ b/.github/actions/setup-ruby/action.yml @@ -0,0 +1,67 @@ +name: "Setup Ruby" +description: "Put a Ruby from the runner's tool cache on PATH" + +# The org Actions policy allows GitHub-owned actions/* only — no Marketplace, no +# verified creators — so ruby/setup-ruby is unavailable here. The runner image +# already ships a Ruby tool cache, so nothing is downloaded: this only selects +# from what is on the box. +# +# Deliberately does NOT install gems. Bundler must run after the Artifactory OIDC +# step has pointed it at the curated mirror; ruby/setup-ruby's `bundler-cache` +# ran `bundle install` before that step, resolving straight from rubygems.org. + +inputs: + ruby-version: + description: "MAJOR.MINOR to select, e.g. '3.3'. The newest matching patch in the tool cache wins." + required: true + +outputs: + ruby-version: + description: "Exact version selected, e.g. '3.3.12'" + value: ${{ steps.select.outputs.ruby-version }} + +runs: + using: "composite" + steps: + - name: Select Ruby from the tool cache + id: select + shell: bash + env: + REQUESTED: ${{ inputs.ruby-version }} + run: | + set -euo pipefail + + case "$(uname -m)" in + x86_64) ARCH=x64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) echo "::error::Unsupported runner architecture $(uname -m)"; exit 1 ;; + esac + + CACHE="${RUNNER_TOOL_CACHE}/Ruby" + if [ ! -d "${CACHE}" ]; then + echo "::error::No Ruby tool cache at ${CACHE}. This runner image does not ship one; the matrix must be pinned to a version it does provide." + exit 1 + fi + + # sort -V so 3.3.10 ranks above 3.3.9. + SELECTED="" + while IFS= read -r candidate; do + [ -x "${candidate}/${ARCH}/bin/ruby" ] && SELECTED="${candidate}" + done < <(find "${CACHE}" -mindepth 1 -maxdepth 1 -type d -name "${REQUESTED}.*" | sort -V) + + if [ -z "${SELECTED}" ]; then + echo "::error::Ruby ${REQUESTED}.x is not in the tool cache. Available: $(find "${CACHE}" -mindepth 1 -maxdepth 1 -type d -printf '%f ' 2>/dev/null)" + exit 1 + fi + + echo "${SELECTED}/${ARCH}/bin" >> "${GITHUB_PATH}" + echo "ruby-version=$(basename "${SELECTED}")" >> "${GITHUB_OUTPUT}" + echo "Selected Ruby $(basename "${SELECTED}") (${ARCH}) from the tool cache" + + - name: Verify toolchain + shell: bash + run: | + set -euo pipefail + ruby --version + gem --version + bundle --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 245b591b..5e86f5cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,16 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + uses: ./.github/actions/setup-ruby with: ruby-version: '3.3' - bundler-cache: true + + - name: Restore gem cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: vendor/bundle + key: bundle-${{ runner.os }}-ruby3.3-${{ hashFiles('Gemfile.lock') }} + restore-keys: bundle-${{ runner.os }}-ruby3.3- - name: Authenticate with Artifactory if: ${{ !github.event.pull_request.head.repo.fork }} @@ -35,7 +41,9 @@ jobs: artifactory_url: ${{ env.ARTIFACTORY_URL }} - name: Install dependencies - run: bundle install + run: | + bundle config set --local path vendor/bundle + bundle install --jobs 4 - name: Run RuboCop run: bundle exec rubocop @@ -46,17 +54,26 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: ['3.1', '3.2', '3.3'] + # Bounded by what the runner image caches — see .github/actions/setup-ruby. + # 3.1 was dropped when ruby/setup-ruby went away: it left upstream support + # in March 2025 and the image does not carry it. + ruby-version: ['3.2', '3.3', '3.4'] steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true + + - name: Restore gem cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: vendor/bundle + key: bundle-${{ runner.os }}-ruby${{ matrix.ruby-version }}-${{ hashFiles('Gemfile.lock') }} + restore-keys: bundle-${{ runner.os }}-ruby${{ matrix.ruby-version }}- - name: Authenticate with Artifactory if: ${{ !github.event.pull_request.head.repo.fork }} @@ -65,7 +82,9 @@ jobs: artifactory_url: ${{ env.ARTIFACTORY_URL }} - name: Install dependencies - run: bundle install + run: | + bundle config set --local path vendor/bundle + bundle install --jobs 4 - name: Run tests run: bundle exec rake @@ -80,10 +99,16 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + uses: ./.github/actions/setup-ruby with: ruby-version: '3.3' - bundler-cache: true + + - name: Restore gem cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: vendor/bundle + key: bundle-${{ runner.os }}-ruby3.3-${{ hashFiles('Gemfile.lock') }} + restore-keys: bundle-${{ runner.os }}-ruby3.3- - name: Authenticate with Artifactory if: ${{ !github.event.pull_request.head.repo.fork }} @@ -92,7 +117,9 @@ jobs: artifactory_url: ${{ env.ARTIFACTORY_URL }} - name: Install dependencies - run: bundle install + run: | + bundle config set --local path vendor/bundle + bundle install --jobs 4 - name: Build gem run: gem build analytics-ruby.gemspec diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6aabe789..b1b3e71b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,10 +21,9 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + uses: ./.github/actions/setup-ruby with: ruby-version: '3.3' - bundler-cache: true - name: Authenticate with Artifactory uses: ./.github/actions/artifactory-oidc @@ -51,7 +50,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Ruby - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + uses: ./.github/actions/setup-ruby with: ruby-version: '3.3' diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 5f86af57..94fb1ea0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -38,12 +38,12 @@ jobs: path: sdk-e2e-tests - name: Setup Ruby - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + uses: ./.github/actions/setup-ruby with: ruby-version: '3.3' - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' From b1af51ff00abe99bede602412f897269ed1f4389 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Fri, 25 Sep 2026 21:38:06 -0400 Subject: [PATCH 06/10] fix(ci): scope rubocop to shipped code, drop Ruby 3.4 from the matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuboCop had never actually run in CI, so 27 offenses had accumulated in e2e-cli/ — a standalone harness with its own Gemfile that the gemspec does not ship. Excluded rather than restyled, matching how spec/ is already treated. Ruby 3.4 is in the tool cache but activesupport 5.2 needs base64, which 3.4 dropped from the default gems. 3.2 and 3.3 both pass. --- .github/workflows/ci.yml | 8 +++++--- .rubocop.yml | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e86f5cd..ee3dcd5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,9 +55,11 @@ jobs: fail-fast: false matrix: # Bounded by what the runner image caches — see .github/actions/setup-ruby. - # 3.1 was dropped when ruby/setup-ruby went away: it left upstream support - # in March 2025 and the image does not carry it. - ruby-version: ['3.2', '3.3', '3.4'] + # 3.1 left upstream support in March 2025 and the image does not carry it. + # 3.4 is cached and selectable, but activesupport 5.2 (a test-only dep) + # requires base64, which 3.4 removed from the default gems. Adding the + # base64 gem and regenerating Gemfile.lock would admit it. + ruby-version: ['3.2', '3.3'] steps: - name: Checkout diff --git a/.rubocop.yml b/.rubocop.yml index ecabcc43..d227a85d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,6 +4,10 @@ AllCops: TargetRubyVersion: '2.0' SuggestExtensions: false NewCops: disable + Exclude: + # Standalone cross-SDK test harness: carries its own Gemfile and is absent + # from the gemspec's spec.files, so it is not held to the gem's style. + - 'e2e-cli/**/*' Layout/FirstHashElementIndentation: EnforcedStyle: consistent From beda56b4929adc3447fced232ee390ead3aa479c Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Fri, 25 Sep 2026 21:50:10 -0400 Subject: [PATCH 07/10] fix(ci): restate RuboCop's default excludes alongside the e2e-cli one An AllCops Exclude replaces the default list instead of extending it, so adding e2e-cli silently un-excluded vendor/**, and RuboCop then loaded the .rubocop.yml shipped inside a vendored gem, which requires a plugin this project does not depend on. --- .rubocop.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.rubocop.yml b/.rubocop.yml index d227a85d..a39f71fe 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,6 +5,14 @@ AllCops: SuggestExtensions: false NewCops: disable Exclude: + # Setting Exclude here REPLACES RuboCop's default exclude list rather than + # extending it, so the paths it normally skips have to be restated. Without + # them RuboCop walks vendor/bundle and loads the .rubocop.yml shipped inside + # gems there, which pulls in plugins this project does not depend on. + - 'vendor/**/*' + - '.gem/**/*' + - 'node_modules/**/*' + - '.git/**/*' # Standalone cross-SDK test harness: carries its own Gemfile and is absent # from the gemspec's spec.files, so it is not held to the gem's style. - 'e2e-cli/**/*' From d6038993e9e8508d60704f532665a1d975be8328 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Fri, 25 Sep 2026 22:56:34 -0400 Subject: [PATCH 08/10] refactor(ci): use twilio/sdk-actions for Artifactory OIDC Replaces the repo-local copy with the shared first-party action, pinned. Each SDK had its own drifting copy of the same exchange; the shared one covers every ecosystem we use, so fixes land once instead of six times. --- .github/actions/artifactory-oidc/action.yml | 71 --------------------- .github/workflows/ci.yml | 15 +++-- .github/workflows/deploy.yml | 5 +- 3 files changed, 12 insertions(+), 79 deletions(-) delete mode 100644 .github/actions/artifactory-oidc/action.yml diff --git a/.github/actions/artifactory-oidc/action.yml b/.github/actions/artifactory-oidc/action.yml deleted file mode 100644 index c6b7ffcd..00000000 --- a/.github/actions/artifactory-oidc/action.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: "Artifactory OIDC Auth" -description: "Exchange GitHub OIDC token for Artifactory access token and configure Bundler" - -inputs: - artifactory-url: - description: "JFrog platform base URL. Falls back to ARTIFACTORY_URL env var." - required: false - default: "" - oidc-provider-name: - description: "OIDC provider name configured in Artifactory" - required: false - default: "github-actions-segmentio" - gems-repo: - description: "Artifactory virtual RubyGems repository name" - required: false - default: "virtual-gems-thirdparty" - -runs: - using: "composite" - steps: - - name: Exchange GitHub OIDC token for Artifactory token - shell: bash - env: - INPUT_ARTIFACTORY_URL: ${{ inputs.artifactory-url }} - OIDC_PROVIDER_NAME: ${{ inputs.oidc-provider-name }} - GEMS_REPO: ${{ inputs.gems-repo }} - run: | - set -euo pipefail - ARTIFACTORY_URL="${INPUT_ARTIFACTORY_URL:-${ARTIFACTORY_URL:-}}" - if [ -z "${ARTIFACTORY_URL}" ]; then - echo "::error::ARTIFACTORY_URL is not set (pass as input or set as env var)"; exit 1 - fi - - OIDC_JWT=$(curl -sS \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${ARTIFACTORY_URL}" \ - -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" | jq -r '.value') - - if [ -z "$OIDC_JWT" ] || [ "$OIDC_JWT" = "null" ]; then - echo "::error::Failed to obtain GitHub OIDC token"; exit 1 - fi - - decode_seg() { local s="${1}"; local m=$(( ${#s} % 4 )); [ $m -ne 0 ] && s="${s}$(printf '=%.0s' $(seq 1 $((4-m))))"; echo "$s" | tr '_-' '/+' | base64 -d 2>/dev/null; } - PAYLOAD=$(decode_seg "$(echo "$OIDC_JWT" | cut -d. -f2)") - echo "OIDC token claims:" - echo " sub = $(echo "$PAYLOAD" | jq -r '.sub')" - echo " aud = $(echo "$PAYLOAD" | jq -r '.aud')" - echo " iss = $(echo "$PAYLOAD" | jq -r '.iss')" - - RESP=$(curl -sS "${ARTIFACTORY_URL}/access/api/v1/oidc/token" \ - -H 'Content-Type: application/json' \ - -d "{\"grant_type\":\"urn:ietf:params:oauth:grant-type:token-exchange\", - \"subject_token_type\":\"urn:ietf:params:oauth:token-type:id_token\", - \"subject_token\":\"${OIDC_JWT}\", - \"provider_name\":\"${OIDC_PROVIDER_NAME}\"}") - - ART_TOKEN=$(echo "$RESP" | jq -r '.access_token // empty') - - if [ -z "$ART_TOKEN" ]; then - echo "::error::OIDC token exchange failed." - echo "$RESP" | jq 'walk(if type == "object" then with_entries(if (.key | test("token"; "i")) then .value = "" else . end) else . end)' 2>/dev/null \ - || echo "::error::(response withheld — not valid JSON)" - exit 1 - fi - echo "::add-mask::$ART_TOKEN" - - HOST=$(echo "${ARTIFACTORY_URL}" | sed -E 's#^https?://##') - MIRROR_URL="https://${HOST}/artifactory/api/gems/${GEMS_REPO}/" - - bundle config mirror.https://rubygems.org "${MIRROR_URL}" - bundle config "https://${HOST}/artifactory/api/gems/${GEMS_REPO}/" ":${ART_TOKEN}" - echo "Configured Bundler to resolve through Artifactory (${GEMS_REPO})" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee3dcd5e..429a225e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,9 +36,10 @@ jobs: - name: Authenticate with Artifactory if: ${{ !github.event.pull_request.head.repo.fork }} - uses: ./.github/actions/artifactory-oidc + uses: twilio/sdk-actions/artifactory-oidc@c94e420aa64ea686ff25bb03d4c66cdaf8e523e4 # main with: - artifactory_url: ${{ env.ARTIFACTORY_URL }} + ecosystem: ruby + provider-name: github-actions-segmentio - name: Install dependencies run: | @@ -79,9 +80,10 @@ jobs: - name: Authenticate with Artifactory if: ${{ !github.event.pull_request.head.repo.fork }} - uses: ./.github/actions/artifactory-oidc + uses: twilio/sdk-actions/artifactory-oidc@c94e420aa64ea686ff25bb03d4c66cdaf8e523e4 # main with: - artifactory_url: ${{ env.ARTIFACTORY_URL }} + ecosystem: ruby + provider-name: github-actions-segmentio - name: Install dependencies run: | @@ -114,9 +116,10 @@ jobs: - name: Authenticate with Artifactory if: ${{ !github.event.pull_request.head.repo.fork }} - uses: ./.github/actions/artifactory-oidc + uses: twilio/sdk-actions/artifactory-oidc@c94e420aa64ea686ff25bb03d4c66cdaf8e523e4 # main with: - artifactory_url: ${{ env.ARTIFACTORY_URL }} + ecosystem: ruby + provider-name: github-actions-segmentio - name: Install dependencies run: | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1b3e71b..141355aa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,9 +26,10 @@ jobs: ruby-version: '3.3' - name: Authenticate with Artifactory - uses: ./.github/actions/artifactory-oidc + uses: twilio/sdk-actions/artifactory-oidc@c94e420aa64ea686ff25bb03d4c66cdaf8e523e4 # main with: - artifactory_url: ${{ env.ARTIFACTORY_URL }} + ecosystem: ruby + provider-name: github-actions-segmentio - name: Install dependencies run: bundle install From ff41b0d149b171f655da1c802df073596e43be70 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Mon, 28 Sep 2026 12:09:10 -0400 Subject: [PATCH 09/10] feat(ci): publish to RubyGems via inline trusted publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exchanges the GitHub OIDC token for a push-scoped RubyGems key that expires in 15 minutes, so no long-lived GEM_HOST_API_KEY is stored anywhere. Done inline instead of with rubygems/release-gem for two reasons. That action is not on the org allow-list, and it nests rubygems/configure-rubygems-credentials, so permitting it needs two entries rather than one. It also drives `rake release`, and Bundler's version_tag is always "#{tag_prefix}v#{version}" — the v is not configurable — while every tag this gem has ever published is bare, e.g. 2.5.0. Also adds a guard that the release tag matches lib/segment/analytics/version.rb before anything is pushed. Requires a trusted publisher registered on the gem for segmentio/analytics-ruby, workflow deploy.yml, environment rubygems. --- .github/workflows/deploy.yml | 51 ++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 141355aa..37cf5534 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -55,8 +55,55 @@ jobs: with: ruby-version: '3.3' + - name: Verify tag matches the gem version + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + TAG="${TAG#v}" + VERSION=$(sed -nE "s/.*VERSION *= *'([^']+)'.*/\\1/p" lib/segment/analytics/version.rb) + if [ "$TAG" != "$VERSION" ]; then + echo "::error::Release tag $TAG does not match VERSION $VERSION in lib/segment/analytics/version.rb" + exit 1 + fi + echo "Releasing $VERSION" + - name: Build gem run: gem build analytics-ruby.gemspec - - name: Publish to RubyGems (Trusted Publishing) - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1 + # RubyGems trusted publishing, done inline rather than via + # rubygems/release-gem. That action is not on the org allow-list (it also + # nests rubygems/configure-rubygems-credentials, so it would need two + # entries), and it drives `rake release`, whose tag name is always + # "v#{version}" — this gem has always tagged bare, e.g. 2.5.0. + - name: Publish to RubyGems (trusted publishing) + run: | + set -euo pipefail + + # aud must equal the RubyGems host exactly; the exchange enforces it. + JWT=$(curl -sS \ + -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=rubygems.org" \ + | jq -r '.value') + + if [ -z "$JWT" ] || [ "$JWT" = "null" ]; then + echo "::error::No GitHub OIDC token. The job needs 'permissions: id-token: write'." + exit 1 + fi + + # Returns a push-scoped key that expires in 15 minutes. Requires a + # trusted publisher registered on the gem for this repo, workflow + # filename and environment. + RESP=$(curl -sS -X POST \ + --data-urlencode "jwt=${JWT}" \ + https://rubygems.org/api/v1/oidc/trusted_publisher/exchange_token) + + KEY=$(echo "$RESP" | jq -r '.rubygems_api_key // empty') + if [ -z "$KEY" ]; then + echo "::error::RubyGems token exchange failed." + echo "$RESP" | jq 'del(.rubygems_api_key)' 2>/dev/null \ + || echo "::error::(response withheld - not valid JSON)" + exit 1 + fi + echo "::add-mask::$KEY" + + GEM_HOST_API_KEY="$KEY" gem push analytics-ruby-*.gem From af180494294e96c661864aee159ef30f4193af64 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Mon, 28 Sep 2026 12:16:08 -0400 Subject: [PATCH 10/10] fix(ci): publish from the protected production environment The job named 'rubygems', which does not exist on this repo. GitHub creates an environment on first reference with no protection rules, so the publish would have run unreviewed while appearing gated. 'production' already exists and requires review from libraries-web-team. The name must also match the trusted publisher registered on rubygems.org. --- .github/workflows/deploy.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 37cf5534..72d9e2e3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,7 +41,10 @@ jobs: name: Publish to RubyGems runs-on: ubuntu-x64 needs: [test] - environment: rubygems + # Must name an environment that already exists with its protection rules; + # GitHub silently creates an unprotected one for any name it does not know. + # `production` carries required_reviewers: libraries-web-team. + environment: production permissions: id-token: write contents: read