diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml new file mode 100644 index 0000000..2689b0f --- /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 new file mode 100644 index 0000000..429a225 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,140 @@ +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: ./.github/actions/setup-ruby + with: + ruby-version: '3.3' + + - 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 }} + uses: twilio/sdk-actions/artifactory-oidc@c94e420aa64ea686ff25bb03d4c66cdaf8e523e4 # main + with: + ecosystem: ruby + provider-name: github-actions-segmentio + + - name: Install dependencies + run: | + bundle config set --local path vendor/bundle + bundle install --jobs 4 + + - 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: + # Bounded by what the runner image caches — see .github/actions/setup-ruby. + # 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 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Ruby + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version }} + + - 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 }} + uses: twilio/sdk-actions/artifactory-oidc@c94e420aa64ea686ff25bb03d4c66cdaf8e523e4 # main + with: + ecosystem: ruby + provider-name: github-actions-segmentio + + - name: Install dependencies + run: | + bundle config set --local path vendor/bundle + bundle install --jobs 4 + + - 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: ./.github/actions/setup-ruby + with: + ruby-version: '3.3' + + - 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 }} + uses: twilio/sdk-actions/artifactory-oidc@c94e420aa64ea686ff25bb03d4c66cdaf8e523e4 # main + with: + ecosystem: ruby + provider-name: github-actions-segmentio + + - name: Install dependencies + run: | + bundle config set --local path vendor/bundle + bundle install --jobs 4 + + - 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 0000000..72d9e2e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,112 @@ +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: ./.github/actions/setup-ruby + with: + ruby-version: '3.3' + + - name: Authenticate with Artifactory + uses: twilio/sdk-actions/artifactory-oidc@c94e420aa64ea686ff25bb03d4c66cdaf8e523e4 # main + with: + ecosystem: ruby + provider-name: github-actions-segmentio + + - name: Install dependencies + run: bundle install + + - name: Run tests + run: bundle exec rake + + deploy: + name: Publish to RubyGems + runs-on: ubuntu-x64 + needs: [test] + # 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 + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Ruby + uses: ./.github/actions/setup-ruby + 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 + + # 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 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 0180b0e..94fb1ea 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -12,32 +12,38 @@ 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: ./.github/actions/setup-ruby with: - ruby-version: '3.2' + ruby-version: '3.3' - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' @@ -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 fa7f8ae..4509d03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ *.gem -Gemfile.lock .ruby-version coverage/ +.claude/ .bundle/ vendor/ *-plan.md diff --git a/.rubocop.yml b/.rubocop.yml index ecabcc4..a39f71f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,6 +4,18 @@ AllCops: TargetRubyVersion: '2.0' 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/**/*' Layout/FirstHashElementIndentation: EnforcedStyle: consistent diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..40c20dc --- /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