diff --git a/.devcontainer/run b/.devcontainer/run index caf752a83..6f0f9dee9 100755 --- a/.devcontainer/run +++ b/.devcontainer/run @@ -22,6 +22,10 @@ if [[ ! -x "$MISE_BIN" ]]; then exit 1 fi +# .mise.toml lists the whole Ruby matrix for bin/relock; the devcontainer profile +# overrides it to pin the single Ruby baked into the image. +export MISE_ENV=devcontainer + # Activate mise for this shell so PATH/shims are resolved correctly. eval "$("$MISE_BIN" activate bash)" diff --git a/.github/workflows/update_lockfiles.yml b/.github/workflows/update_lockfiles.yml index c1db9d72f..773cdf0a5 100644 --- a/.github/workflows/update_lockfiles.yml +++ b/.github/workflows/update_lockfiles.yml @@ -4,10 +4,9 @@ name: Update lockfiles # installs are fully pinned (supply-chain hardening). Run manually to create the # initial lockfiles, and on a schedule to refresh them deliberately. # -# Each gen- job derives its cells from that gem's test-matrix.json — the -# single source of truth, shared with the *_test.yml workflows. `options`/RUBYOPT -# don't affect dependency resolution, so they're stripped and cells deduped here -# (one lockfile per cell). +# bin/relock sweeps every gem's test-matrix.json — the single source of truth, +# shared with the *_test.yml workflows — installing each cell's Ruby via mise +# and resolving every cell. One invocation regenerates the whole lock set. on: workflow_dispatch: @@ -19,304 +18,10 @@ permissions: contents: write jobs: - matrix: - runs-on: ubuntu-latest - outputs: - sentry_ruby: ${{ steps.set.outputs.sentry_ruby }} - sentry_rails: ${{ steps.set.outputs.sentry_rails }} - sentry_sidekiq: ${{ steps.set.outputs.sentry_sidekiq }} - sentry_resque: ${{ steps.set.outputs.sentry_resque }} - sentry_delayed_job: ${{ steps.set.outputs.sentry_delayed_job }} - sentry_opentelemetry: ${{ steps.set.outputs.sentry_opentelemetry }} - sentry_yabeda: ${{ steps.set.outputs.sentry_yabeda }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # Strip rubyopt (irrelevant to resolution) and dedup, so each cell maps to - # exactly one lockfile artifact (no name collisions on the rubyopt twins). - - id: set - run: | - for gem in sentry-ruby sentry-rails sentry-sidekiq sentry-resque sentry-delayed_job sentry-opentelemetry sentry-yabeda; do - cells=$(jq -c 'map(del(.options)) | unique' "$gem/test-matrix.json") - echo "${gem//-/_}=$cells" >> "$GITHUB_OUTPUT" - done - - gen-sentry-ruby: - needs: matrix - name: lock sentry-ruby ${{ matrix.ruby_version }} / rack ${{ matrix.rack_version }} / redis ${{ matrix.redis_rb_version }} - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: sentry-ruby - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/sentry-ruby/gemfiles/ruby-${{ matrix.ruby_version }}_rack-${{ matrix.rack_version }}_redis-${{ matrix.redis_rb_version }}.gemfile - RACK_VERSION: ${{ matrix.rack_version }} - REDIS_RB_VERSION: ${{ matrix.redis_rb_version }} - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.matrix.outputs.sentry_ruby) }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # Wrapper keys the lock to this cell (.gemfile -> .gemfile.lock). - # Must exist before setup-ruby, which errors if BUNDLE_GEMFILE points at a missing file. - - name: Write wrapper gemfile - run: | - mkdir -p gemfiles - echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1 - with: - ruby-version: ${{ matrix.ruby_version }} - bundler: latest - bundler-cache: false - - name: Resolve lockfile - run: | - bundle lock --update - # Checksums need Bundler >= 2.5 (Ruby >= 3.0); older Rubies get version pinning only. - bundle lock --add-checksums || echo "::warning::--add-checksums unsupported on $(bundle --version); version pinning only for ${{ matrix.ruby_version }}" - - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 - with: - name: lock-sentry-ruby-${{ matrix.ruby_version }}-${{ matrix.rack_version }}-${{ matrix.redis_rb_version }} - # Leading wildcard keeps the repo-relative path (sentry-ruby/gemfiles/...) - # inside the artifact. Only the lock is committed; the wrapper .gemfile is - # regenerated on the fly wherever it's needed. - path: "*/gemfiles/ruby-${{ matrix.ruby_version }}_rack-${{ matrix.rack_version }}_redis-${{ matrix.redis_rb_version }}.gemfile.lock" - if-no-files-found: error - - gen-sentry-rails: - needs: matrix - name: lock sentry-rails ${{ matrix.ruby_version }} / rails ${{ matrix.rails_version }} - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: sentry-rails - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/sentry-rails/gemfiles/ruby-${{ matrix.ruby_version }}_rails-${{ matrix.rails_version }}.gemfile - RAILS_VERSION: ${{ matrix.rails_version }} - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.matrix.outputs.sentry_rails) }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # Wrapper keys the lock to this cell (.gemfile -> .gemfile.lock). - # Must exist before setup-ruby, which errors if BUNDLE_GEMFILE points at a missing file. - - name: Write wrapper gemfile - run: | - mkdir -p gemfiles - echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1 - with: - ruby-version: ${{ matrix.ruby_version }} - bundler: latest - bundler-cache: false - - name: Resolve lockfile - run: | - bundle lock --update - bundle lock --add-checksums || echo "::warning::--add-checksums unsupported on $(bundle --version); version pinning only for ${{ matrix.ruby_version }}" - - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 - with: - name: lock-sentry-rails-${{ matrix.ruby_version }}-${{ matrix.rails_version }} - path: "*/gemfiles/ruby-${{ matrix.ruby_version }}_rails-${{ matrix.rails_version }}.gemfile.lock" - if-no-files-found: error - - gen-sentry-sidekiq: - needs: matrix - name: lock sentry-sidekiq ${{ matrix.ruby_version }} / sidekiq ${{ matrix.sidekiq_version }} - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: sentry-sidekiq - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/sentry-sidekiq/gemfiles/ruby-${{ matrix.ruby_version }}_sidekiq-${{ matrix.sidekiq_version }}.gemfile - SIDEKIQ_VERSION: ${{ matrix.sidekiq_version }} - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.matrix.outputs.sentry_sidekiq) }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # Wrapper keys the lock to this cell (.gemfile -> .gemfile.lock). - # Must exist before setup-ruby, which errors if BUNDLE_GEMFILE points at a missing file. - - name: Write wrapper gemfile - run: | - mkdir -p gemfiles - echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1 - with: - ruby-version: ${{ matrix.ruby_version }} - bundler: latest - bundler-cache: false - - name: Resolve lockfile - run: | - bundle lock --update - bundle lock --add-checksums || echo "::warning::--add-checksums unsupported on $(bundle --version); version pinning only for ${{ matrix.ruby_version }}" - - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 - with: - name: lock-sentry-sidekiq-${{ matrix.ruby_version }}-${{ matrix.sidekiq_version }} - path: "*/gemfiles/ruby-${{ matrix.ruby_version }}_sidekiq-${{ matrix.sidekiq_version }}.gemfile.lock" - if-no-files-found: error - - gen-sentry-resque: - needs: matrix - name: lock sentry-resque ${{ matrix.ruby_version }} - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: sentry-resque - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/sentry-resque/gemfiles/ruby-${{ matrix.ruby_version }}.gemfile - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.matrix.outputs.sentry_resque) }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # Wrapper keys the lock to this cell (.gemfile -> .gemfile.lock). - # Must exist before setup-ruby, which errors if BUNDLE_GEMFILE points at a missing file. - - name: Write wrapper gemfile - run: | - mkdir -p gemfiles - echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1 - with: - ruby-version: ${{ matrix.ruby_version }} - bundler: latest - bundler-cache: false - - name: Resolve lockfile - run: | - bundle lock --update - bundle lock --add-checksums || echo "::warning::--add-checksums unsupported on $(bundle --version); version pinning only for ${{ matrix.ruby_version }}" - - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 - with: - name: lock-sentry-resque-${{ matrix.ruby_version }} - path: "*/gemfiles/ruby-${{ matrix.ruby_version }}.gemfile.lock" - if-no-files-found: error - - gen-sentry-delayed_job: - needs: matrix - name: lock sentry-delayed_job ${{ matrix.ruby_version }} - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: sentry-delayed_job - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/sentry-delayed_job/gemfiles/ruby-${{ matrix.ruby_version }}.gemfile - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.matrix.outputs.sentry_delayed_job) }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # Wrapper keys the lock to this cell (.gemfile -> .gemfile.lock). - # Must exist before setup-ruby, which errors if BUNDLE_GEMFILE points at a missing file. - - name: Write wrapper gemfile - run: | - mkdir -p gemfiles - echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1 - with: - ruby-version: ${{ matrix.ruby_version }} - bundler: latest - bundler-cache: false - - name: Resolve lockfile - run: | - bundle lock --update - bundle lock --add-checksums || echo "::warning::--add-checksums unsupported on $(bundle --version); version pinning only for ${{ matrix.ruby_version }}" - - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 - with: - name: lock-sentry-delayed_job-${{ matrix.ruby_version }} - path: "*/gemfiles/ruby-${{ matrix.ruby_version }}.gemfile.lock" - if-no-files-found: error - - gen-sentry-opentelemetry: - needs: matrix - name: lock sentry-opentelemetry ${{ matrix.ruby_version }} - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: sentry-opentelemetry - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/sentry-opentelemetry/gemfiles/ruby-${{ matrix.ruby_version }}.gemfile - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.matrix.outputs.sentry_opentelemetry) }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # Wrapper keys the lock to this cell (.gemfile -> .gemfile.lock). - # Must exist before setup-ruby, which errors if BUNDLE_GEMFILE points at a missing file. - - name: Write wrapper gemfile - run: | - mkdir -p gemfiles - echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1 - with: - ruby-version: ${{ matrix.ruby_version }} - bundler: latest - bundler-cache: false - - name: Resolve lockfile - run: | - bundle lock --update - bundle lock --add-checksums || echo "::warning::--add-checksums unsupported on $(bundle --version); version pinning only for ${{ matrix.ruby_version }}" - - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 - with: - name: lock-sentry-opentelemetry-${{ matrix.ruby_version }} - path: "*/gemfiles/ruby-${{ matrix.ruby_version }}.gemfile.lock" - if-no-files-found: error - - gen-sentry-yabeda: - needs: matrix - name: lock sentry-yabeda ${{ matrix.ruby_version }} - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: sentry-yabeda - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/sentry-yabeda/gemfiles/ruby-${{ matrix.ruby_version }}.gemfile - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.matrix.outputs.sentry_yabeda) }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # Wrapper keys the lock to this cell (.gemfile -> .gemfile.lock). - # Must exist before setup-ruby, which errors if BUNDLE_GEMFILE points at a missing file. - - name: Write wrapper gemfile - run: | - mkdir -p gemfiles - echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" - - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1 - with: - ruby-version: ${{ matrix.ruby_version }} - bundler: latest - bundler-cache: false - - name: Resolve lockfile - run: | - bundle lock --update - bundle lock --add-checksums || echo "::warning::--add-checksums unsupported on $(bundle --version); version pinning only for ${{ matrix.ruby_version }}" - - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 - with: - name: lock-sentry-yabeda-${{ matrix.ruby_version }} - path: "*/gemfiles/ruby-${{ matrix.ruby_version }}.gemfile.lock" - if-no-files-found: error - - commit: - needs: - - gen-sentry-ruby - - gen-sentry-rails - - gen-sentry-sidekiq - - gen-sentry-resque - - gen-sentry-delayed_job - - gen-sentry-opentelemetry - - gen-sentry-yabeda + update: runs-on: ubuntu-latest + # Resolves every cell across all gems serially, each under its own Ruby. + timeout-minutes: 120 permissions: contents: write pull-requests: write @@ -324,11 +29,17 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: token: ${{ secrets.GITHUB_TOKEN }} - # Each artifact carries its repo-relative path, so merging them straight into - # the workspace lands every pair back at /gemfiles/ — no routing. - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + + # mise installs every Ruby declared in .mise.toml (ruby.compile=false -> + # prebuilt binaries where available); bin/relock then resolves each cell + # against its matching Ruby. + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 with: - merge-multiple: true + install: true + cache: true + + - name: Regenerate lockfiles + run: ruby bin/relock - name: Configure git run: | @@ -339,7 +50,7 @@ jobs: id: create-branch run: | # Stage first, then diff the index against HEAD. `git diff` alone only - # sees tracked files, so newly generated (untracked) pairs — i.e. the + # sees tracked files, so newly generated (untracked) locks — i.e. the # bootstrap run and any filled-in missing cell — would otherwise look # like "no change" and never get pushed. git add '**/gemfiles/*.gemfile.lock' @@ -353,6 +64,22 @@ jobs: COMMIT_TITLE="ci: 🤖 Update pinned CI lockfiles" BRANCH_NAME="lockfiles/update-$(date +%m-%d)" + # Which gems changed: the top-level dir of each touched lock. + GEMS=$(git diff --cached --name-only -- '**/gemfiles/*.gemfile.lock' | cut -d/ -f1 | sort -u) + + # Aggregate dependency version changes across every touched lock. Pair + # the removed (-) and added (+) "name (version)" spec lines per gem so + # the summary reads "gem: old → new" (deduped across cells). + DEPS=$(git diff --cached -U0 -- '**/gemfiles/*.gemfile.lock' \ + | { grep -E '^[+-] +[A-Za-z0-9_.-]+ \([0-9]' || true; } \ + | sed -E 's/^([+-]) +([A-Za-z0-9_.-]+) \(([^)]+)\).*/\1 \2 \3/' \ + | awk '{ if ($1 == "-") old[$2] = $3; else neu[$2] = $3 } + END { + for (g in neu) if (neu[g] != old[g]) printf "- `%s`: %s → %s\n", g, (g in old ? old[g] : "new"), neu[g] + for (g in old) if (!(g in neu)) printf "- `%s`: %s → removed\n", g, old[g] + }' \ + | sort -u) + git checkout -B "$BRANCH_NAME" git commit -m "$COMMIT_TITLE" git push origin "$BRANCH_NAME" --force @@ -360,6 +87,10 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" echo "commit_title=$COMMIT_TITLE" >> "$GITHUB_OUTPUT" + { + echo "gems<> "$GITHUB_OUTPUT" - name: Create pull request if: steps.create-branch.outputs.changed == 'true' @@ -367,14 +98,26 @@ jobs: env: BRANCH_NAME: ${{ steps.create-branch.outputs.branch_name }} COMMIT_TITLE: ${{ steps.create-branch.outputs.commit_title }} + GEMS: ${{ steps.create-branch.outputs.gems }} + DEPS: ${{ steps.create-branch.outputs.deps }} with: script: | const branchName = process.env.BRANCH_NAME; const commitTitle = process.env.COMMIT_TITLE; + const gems = (process.env.GEMS || '').trim().split('\n').filter(Boolean); + const deps = (process.env.DEPS || '').trim(); + const gemsList = gems.length ? gems.map(g => `\`${g}\``).join(', ') : '_none_'; + const depsBlock = deps || '_No dependency version changes (checksum/metadata only)._'; const prBody = `Automated regeneration of the per-matrix lockfiles used to pin CI dependencies (supply-chain hardening). #skip-changelog + ## Gems updated + ${gemsList} + + ## Dependency changes + ${depsBlock} + ## Action required - If CI passes on this PR, it's safe to approve and merge: the refreshed pins resolve and the suite is green. - If CI fails, a dependency update broke something — investigate before merging. diff --git a/.mise.devcontainer.toml b/.mise.devcontainer.toml new file mode 100644 index 000000000..69d4f2b19 --- /dev/null +++ b/.mise.devcontainer.toml @@ -0,0 +1,4 @@ +# Loaded only when MISE_ENV=devcontainer (set by .devcontainer/run). Pins Ruby to +# the single version baked into the image, overriding the relock matrix in .mise.toml. +[tools] +ruby = "{{ env.RUBY_VERSION | default(value='latest') }}" diff --git a/.mise.toml b/.mise.toml index dd108298f..db5976284 100644 --- a/.mise.toml +++ b/.mise.toml @@ -2,7 +2,18 @@ ruby.compile = false [tools] -ruby = "latest" +# postinstall ensures latest bundlers possible on each version +ruby = [ + { version = "4.0", postinstall = "gem install bundler" }, + { version = "3.4", postinstall = "gem install bundler" }, + { version = "3.3", postinstall = "gem install bundler" }, + { version = "3.2", postinstall = "gem install bundler" }, + { version = "3.1", postinstall = "gem install bundler" }, + { version = "3.0", postinstall = "gem install bundler" }, + # 2.7 is pinned because latest bundler fails to resolve + { version = "2.7", postinstall = "gem install bundler -v 2.4.22" }, + { version = "jruby-9.4.14.0", postinstall = "gem install bundler" }, +] node = "lts" java = "temurin-21" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 845771384..8a32ea48c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,43 @@ This file defines which specific image and Ruby version will be used to run the - Use example apps under the `example` or `examples` folder to test the change. (Remember to change the DSN first) - To learn more about `sentry-ruby`'s structure, you can read the [Sentry SDK spec] +## Regenerating CI Lockfiles + +CI installs against a committed, checksummed lockfile per test-matrix cell (`/gemfiles/.gemfile.lock`) to keep dependencies fully pinned against supply chain attacks. Each gem's `test-matrix.json` is the source of truth; `bin/relock` materializes the gemfiles and locks from it. + +We use [mise](https://mise.jdx.dev) for managing the ruby versions, so first install that by following official instructions. The required Rubies are declared in `.mise.toml`, so provision them once: + +```bash +mise install # installs every Ruby the matrix needs +``` + +Then regenerate locks: + +```bash +bin/relock # every cell +bin/relock --gem sentry-ruby # one gem +bin/relock --cell sentry-ruby/gemfiles/ruby-3.2_rack-3_redis-5.gemfile # one cell +``` + +In CI, the `Update lockfiles` workflow runs `relock` on a weekly schedule and opens a PR with the refreshed pins. + +### Compiling old rubies + +Old rubies are not available pre-compiled by `mise` so you might need some additional flags depending on your system: + +On latest macos, Ruby 3.0 needs a [patch](https://bugs.ruby-lang.org/issues/20760#note-4) to compile: + +```bash +MISE_RUBY_APPLY_PATCHES="https://github.com/ruby/ruby/commit/1dfe75b0beb7171b8154ff0856d5149be0207724.patch" \ + mise install ruby@3.0 +``` + +On new linuxes with a very new `gcc`, you might need: + +```bash +CFLAGS="-std=gnu11" mise install ruby@2.7 +``` + ## Write Your Sentry Extension Please read the [extension guideline] to learn more. Feel free to open an issue if you find anything missing. diff --git a/bin/relock b/bin/relock new file mode 100755 index 000000000..0927a8fbb --- /dev/null +++ b/bin/relock @@ -0,0 +1,303 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Regenerate the per-matrix CI lockfiles that pin our dependencies +# (supply-chain hardening). Each test-matrix cell gets a committed +# `/gemfiles/.gemfile` wrapper and a `.gemfile.lock`; this script +# generates both. +# +# Each gem's `test-matrix.json` IS the source of truth — we expand cells from +# those declarative matrices (the same files CI reads to build its job matrix), +# so the locks can never drift from the matrix CI actually runs. Edit the matrix +# to add/remove a cell, then run this to materialize the gemfiles and locks. +# +# Each cell must resolve against its own Ruby (gemspecs gate on +# required_ruby_version), so every cell runs under a matching Ruby provided by +# mise (https://mise.jdx.dev). The required Rubies are declared in .mise.toml; +# install them once with `mise install`. This script resolves against those +# already-installed Rubies and aborts if any are missing. +# +# bin/relock # refresh every cell +# bin/relock --gem sentry-ruby # one gem +# bin/relock --cell sentry-ruby/gemfiles/ruby-3.2_rack-3_redis-5.gemfile +# bin/relock --force # delete each lock before resolving +# bin/relock -l # list every cell's lock path +# +# The committed locks are multi-platform (the PLATFORMS section spans +# linux/darwin/java); `bundle lock --update` preserves that list, so resolving +# natively on any host keeps the locks valid for CI's x86_64-linux runners. +# +# See --help for all options. + +require "optparse" +require "json" + +ROOT = File.expand_path("..", __dir__) + +# The gem each matrix axis pins -> env var the gem's Gemfile reads. The gem name +# is also the filename segment (the part before the first "-"); the matrix key +# is "_version" (redis is the exception: redis_rb_version), so we recover +# the gem from a matrix key with key.split("_").first. +# +# Values pass through verbatim: the matrix already normalizes them the way +# GitHub Actions renders the matrix (rack-2 not rack-2.0), and the Gemfiles wrap +# them in Gem::Version.new(...), so "2" and "2.0" resolve identically — matching CI. +GEM_ENV_MAPPING = { + "rack" => "RACK_VERSION", + "redis" => "REDIS_RB_VERSION", + "rails" => "RAILS_VERSION", + "sidekiq" => "SIDEKIQ_VERSION" +}.freeze + +Cell = Struct.new(:gem, :base, :ruby, :env, keyword_init: true) do + def wrapper + "#{gem}/gemfiles/#{base}.gemfile" + end + + def lock + "#{wrapper}.lock" + end + + def label + "#{gem} / #{base}" + end +end + +# Expand one test-matrix.json entry into a cell. The entry's keys (in file +# order) become the filename segments and the env the Gemfile reads: +# {"ruby_version":"3.2","rack_version":"2","redis_rb_version":"4"} +# -> ruby-3.2_rack-2_redis-4, {RACK_VERSION=2, REDIS_RB_VERSION=4} +# "options" (e.g. rubyopt) is a test-time concern and doesn't affect resolution. +def cell_from_entry(gem, entry) + ruby = entry.fetch("ruby_version") + segments = ["ruby-#{ruby}"] + env = {} + + entry.each do |key, value| + next if key == "ruby_version" || key == "options" + + name = key.split("_").first + var = GEM_ENV_MAPPING[name] + abort "Unknown matrix key: '#{key}' in #{gem}/test-matrix.json" unless var + segments << "#{name}-#{value}" + env[var] = value + end + + Cell.new(gem: gem, base: segments.join("_"), ruby: ruby, env: env) +end + +# Parse a wrapper/lock path's base name like "ruby-3.2_rack-3_redis-5" back into +# a cell (used by --cell, which addresses a single cell by path). +def parse_cell(gem, base) + segments = base.split("_") + ruby = segments.shift.sub(/\Aruby-/, "") + + env = {} + segments.each do |seg| + name, value = seg.split("-", 2) + var = GEM_ENV_MAPPING[name] + abort "Unknown matrix axis '#{name}' in #{gem}/gemfiles/#{base}" unless var + env[var] = value + end + + Cell.new(gem: gem, base: base, ruby: ruby, env: env) +end + +def matrix_path(gem) + File.join(ROOT, gem, "test-matrix.json") +end + +def discover_cells(gems) + gems.flat_map do |gem| + path = matrix_path(gem) + abort "No test-matrix.json for gem '#{gem}'" unless File.exist?(path) + JSON.parse(File.read(path)).map { |entry| cell_from_entry(gem, entry) }.uniq(&:wrapper) + end +end + +def all_gems + Dir.glob(File.join(ROOT, "*", "test-matrix.json")).map { |p| File.basename(File.dirname(p)) }.sort +end + +# Absolute path to the mise binary. It's usually a shell function (so plain +# `mise` won't resolve via execvp); ask a login shell where the real binary is. +def mise_bin + @mise_bin ||= begin + # `command -v` can emit profile noise on earlier lines; the real path is last. + found = `sh -lc 'command -v mise' 2>/dev/null`.lines.last.to_s.strip + candidates = [ + ENV["MISE_BIN"], + found, + "/opt/homebrew/bin/mise", + File.expand_path("~/.local/bin/mise"), + "/usr/local/bin/mise" + ] + candidates.compact.find { |c| File.executable?(c) } || + abort("mise not found. Install it: https://mise.jdx.dev") + end +end + +# Shell run under the cell's Ruby. Writes the wrapper, re-resolves, and adds +# checksums where the bundler version supports them. When FORCE is set, the +# existing lock is deleted first so bundler resolves from scratch — slower, but +# sidesteps edge cases where an incremental update gets stuck on a stale lock. +RESOLVE = <<~SH + set -euo pipefail + mkdir -p "$(dirname "$BUNDLE_GEMFILE")" + echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" + if [ -n "${FORCE:-}" ]; then + echo "+ FORCE: removing $BUNDLE_GEMFILE.lock" + rm -f "$BUNDLE_GEMFILE.lock" + fi + # --add-checksums needs Bundler >= 2.5; fold it into the update where + # supported, and fall back to a plain update on older Rubies so they don't + # surface a flag-not-found error. + if bundle lock --help 2>&1 | grep -q -- --add-checksums; then + echo "+ bundle lock --update --add-checksums" + bundle lock --update --add-checksums + else + echo "+ bundle lock --update" + bundle lock --update + fi +SH + +# Abort (don't auto-install) if any cell's Ruby is missing — the Rubies are +# declared in .mise.toml and provisioned once via `mise install`. +def ensure_installed(cells) + missing = cells.map(&:ruby).uniq.reject do |spec| + system(mise_bin, "where", "ruby@#{spec}", out: File::NULL, err: File::NULL) + end + return if missing.empty? + + warn "Ruby not installed: #{missing.map { |s| "ruby@#{s}" }.join(', ')}." + abort "Run `mise install` first." +end + +def cell_env(cell, force: false) + env = { "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env) + env["FORCE"] = "1" if force + env +end + +def run_mise(cell, force: false) + # bash -c (not -lc): inherit the PATH/env mise just set; a login shell would + # re-source the profile and reset Ruby back to the host default. + argv = [mise_bin, "exec", "ruby@#{cell.ruby}", "--", "bash", "-c", RESOLVE] + [cell_env(cell, force: force), argv] +end + +# ---- options ------------------------------------------------------------- + +opts = { + gems: [], + cell: nil, + list: false, + force: false +} + +parser = OptionParser.new do |o| + o.banner = "Usage: bin/relock [options]" + o.on("--gem NAME", "Only one gem (repeatable). Default: all.") { |v| opts[:gems] << v } + o.on("--cell PATH", "Resolve exactly one cell by its wrapper/lock path.") { |v| opts[:cell] = v } + o.on("-l", "--list", "List every cell's lock path, one per line; do nothing.") { opts[:list] = true } + o.on("--force", "Delete each lock before resolving (resolve from scratch).") { opts[:force] = true } + o.on("-h", "--help") { puts o; exit 0 } +end +parser.parse!(ARGV) + +# ---- select cells -------------------------------------------------------- + +if opts[:cell] + # Single explicit cell. Derive gem + base by position + # (/gemfiles/.gemfile) so it works for absolute, relative, or + # symlinked paths. Accept either the .gemfile or .gemfile.lock form. + parts = opts[:cell].sub(/\.lock\z/, "").split("/") + gi = parts.rindex("gemfiles") + # Positive (not just non-nil): we need a segment before "gemfiles". + abort "--cell must point at a /gemfiles/.gemfile path" unless gi&.positive? + gem = parts[gi - 1] + base = File.basename(parts[gi + 1], ".gemfile") + cells = [parse_cell(gem, base)] +else + gems = opts[:gems].empty? ? all_gems : opts[:gems] + cells = discover_cells(gems) +end + +if cells.empty? + warn "No matching lockfile cells found." + exit 1 +end + +# ---- list ---------------------------------------------------------------- + +# Bare lock paths, one per line — scriptable (e.g. piped to xargs). +if opts[:list] + cells.each { |c| puts c.lock } + exit 0 +end + +# Resolve against already-installed Rubies (declared in .mise.toml) — never +# auto-installs. +ensure_installed(cells) + +# ---- execute ------------------------------------------------------------- + +# ANSI styling, but only when stdout is an interactive terminal (pipes/CI get +# plain text). $stdout.tty? guards every escape so logs stay grep-friendly. +TTY = $stdout.tty? +def color(text, code) + TTY ? "\e[#{code}m#{text}\e[0m" : text +end + +# Named SGR codes so the output below reads by intent, not magic numbers. +BOLD = "1" +DIM = "90" +CYAN = "36" +BOLD_BLUE = "1;34" +GREEN = "32" +RED = "31" +BOLD_GREEN = "1;32" +BOLD_RED = "1;31" + +def fmt_duration(seconds) + seconds < 60 ? format("%.1fs", seconds) : format("%dm%02ds", (seconds / 60).to_i, (seconds % 60).to_i) +end + +# Serial: cells sharing a Ruby also share that Ruby's gem home, and git-sourced +# gems (e.g. debug) collide on .git locks when fetched concurrently. +failures = [] +width = cells.size.to_s.length +indent = " " * (width * 2 + 3) # aligns the ✓/✗ under the "[n/m]" counter +started = Time.now + +cells.each_with_index do |cell, i| + counter = color("[#{(i + 1).to_s.rjust(width)}/#{cells.size}]", BOLD_BLUE) + puts "#{counter} #{color('→', CYAN)} #{color(cell.label, BOLD)} #{color("(ruby #{cell.ruby})", DIM)}" + + env, argv = run_mise(cell, force: opts[:force]) + cell_started = Time.now + # chdir into the gem dir to match CI's working-directory (avoids stray + # files and keeps any .bundle/ config local to the gem). + ok = system(env, *argv, chdir: File.join(ROOT, cell.gem)) + elapsed = fmt_duration(Time.now - cell_started) + + if ok + puts "#{indent} #{color('✓', GREEN)} done #{color("in #{elapsed}", DIM)}" + else + failures << cell + puts "#{indent} #{color('✗', RED)} #{color("failed after #{elapsed}", RED)}" + end +end + +total_elapsed = fmt_duration(Time.now - started) +ok_count = cells.size - failures.size + +puts +if failures.empty? + puts color("✓ Regenerated #{ok_count}/#{cells.size} cell(s) in #{total_elapsed}.", BOLD_GREEN) +else + puts color("✗ Regenerated #{ok_count}/#{cells.size} cell(s) in #{total_elapsed}.", BOLD_RED) + warn color("Failed:", BOLD_RED) + failures.each { |c| warn color(" #{c.lock}", RED) } + exit 1 +end