Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions .github/workflows/update_lockfiles.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
name: Update lockfiles

# Generates one committed, checksummed lockfile per test-matrix cell so that CI
# installs are fully pinned (supply-chain hardening). Run manually to create the
# initial lockfiles, and on a schedule to refresh them deliberately.
# Generates the committed, checksummed lockfiles that fully pin CI installs
# (supply-chain hardening): one per test-matrix cell, plus one per e2e app under
# spec/apps. Run manually to create the initial lockfiles, and on a schedule to
# refresh them deliberately.
#
# Under MISE_ENV=ci the mise-action step installs every Ruby declared in
# .mise.ci.toml; bin/relock then sweeps every gem's test-matrix.json — the
# single source of truth, shared with the *_test.yml workflows — resolving each
# cell against its matching Ruby via `mise exec`. One invocation regenerates the
# whole lock set.
# cell against its matching Ruby via `mise exec`, and each app against the
# oldest Ruby in the e2e matrix. One invocation regenerates the whole lock set.

on:
workflow_dispatch:
Expand Down Expand Up @@ -60,11 +61,15 @@ jobs:
- name: Create branch
id: create-branch
run: |
# Two patterns because an app locks against a committed Gemfile of its
# own rather than a generated wrapper.
LOCKS=('**/gemfiles/*.gemfile.lock' 'spec/apps/*/Gemfile.lock')

# Stage first, then diff the index against HEAD. `git diff` alone only
# 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'
git add "${LOCKS[@]}"

if git diff --cached --quiet; then
echo "No lockfile changes; nothing to do."
Expand All @@ -76,13 +81,15 @@ jobs:
SHORT_SHA="${GITHUB_SHA:0:12}"
BRANCH_NAME="lockfiles/update-$(date +%m-%d)-${SHORT_SHA}"

# 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)
# What changed: the gem name for a matrix lock, the app name for an app.
GEMS=$(git diff --cached --name-only -- "${LOCKS[@]}" \
| sed -E 's|^spec/apps/([^/]+)/Gemfile\.lock$|\1|; s|^([^/]+)/gemfiles/.*|\1|' \
| 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 --text -U0 -- '**/gemfiles/*.gemfile.lock' \
DEPS=$(git diff --cached --text -U0 -- "${LOCKS[@]}" \
| { 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 }
Expand Down Expand Up @@ -120,11 +127,11 @@ jobs:
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).
const prBody = `Automated regeneration of the committed lockfiles used to pin CI dependencies (supply-chain hardening): one per test-matrix cell, plus the e2e apps.

#skip-changelog

## Gems updated
## Bundles updated
${gemsList}

## Dependency changes
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ log/
*.gem
# Per-matrix lockfiles (<gem>/gemfiles/*.gemfile.lock) ARE committed (supply-chain
# pinning). The wrapper *.gemfile is regenerated on the fly (identical one-liner),
# so ignore it. Default locally-resolved lockfiles also stay ignored.
# so ignore it. Default locally-resolved lockfiles also stay ignored, except the
# e2e rails-mini app: its lock IS committed so every e2e run pins the same
# dependency set rather than resolving whatever is newest.
*/gemfiles/*.gemfile
Gemfile.lock
!spec/apps/rails-mini/Gemfile.lock
.coveralls.yml
.ruby-version
.ruby-gemset
Expand Down
63 changes: 60 additions & 3 deletions bin/lib/matrix.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
# Each cell runs under a matching Ruby provided by mise (https://mise.jdx.dev);
# the required Rubies are declared in .mise.ci.toml and installed with
# `mise --env ci install`.
#
# The e2e apps under spec/apps are cells too, of a second shape: they have a
# Gemfile of their own rather than a generated wrapper, and no matrix axes. Only
# bin/relock expands them (bin/test runs gem specs, not the apps).

require "json"
require "yaml"

module Matrix
ROOT = File.expand_path("../..", __dir__)
Expand All @@ -22,17 +27,35 @@ module Matrix
"sidekiq" => "SIDEKIQ_VERSION"
}.freeze

Cell = Struct.new(:gem, :base, :ruby, :env, :rubyopt, keyword_init: true) do
# Apps whose lock is committed, so that an e2e run pins its dependencies
# instead of resolving whatever is newest — the same reason the gems do.
APP_DIRS = ["spec/apps/rails-mini"].freeze

# The e2e job matrix, i.e. which Rubies the app locks must satisfy.
E2E_WORKFLOW = ".github/workflows/e2e_tests.yml"

Cell = Struct.new(:gem, :base, :ruby, :env, :rubyopt, :dir, :gemfile, keyword_init: true) do
def dir
self[:dir] || gem
end

# Matrix cells resolve through a generated wrapper that eval_gemfile's the
# gem's own Gemfile; apps point straight at theirs.
def wrapper
"#{gem}/gemfiles/#{base}.gemfile"
self[:gemfile] || "#{gem}/gemfiles/#{base}.gemfile"
end

# An app's Gemfile is committed, so only wrappers are ours to (re)write.
def generated_wrapper?
self[:gemfile].nil?
end

def lock
"#{wrapper}.lock"
end

def label
"#{gem} / #{base}"
generated_wrapper? ? "#{gem} / #{base}" : base
end
end

Expand Down Expand Up @@ -116,6 +139,40 @@ def all_gems
Dir.glob(File.join(ROOT, "*", "test-matrix.json")).map { |p| File.basename(File.dirname(p)) }.sort
end

def all_apps
APP_DIRS.map { |dir| File.basename(dir) }
end

# One committed lock per app serves every Ruby in the e2e matrix, so resolve
# under the OLDEST of them: a resolution that installs there installs on the
# newer ones too, not the other way round. The workflow's own matrix is the
# source of truth here, the way test-matrix.json is for the gems.
def oldest_e2e_ruby
@oldest_e2e_ruby ||= begin
rubies = YAML.load_file(File.join(ROOT, E2E_WORKFLOW), aliases: true)
.dig("jobs", "e2e-tests", "strategy", "matrix", "ruby")
abort "Could not read the e2e Ruby matrix from #{E2E_WORKFLOW}" unless rubies.is_a?(Array) && rubies.any?

rubies.map { |r| r.fetch("flavor") }.min_by { |flavor| Gem::Version.new(flavor) }
end
end

def app_cells(apps = all_apps)
apps.map do |app|
dir = APP_DIRS.find { |d| File.basename(d) == app }
abort "Unknown app '#{app}'. Known apps: #{all_apps.join(', ')}" unless dir

Cell.new(base: app, ruby: oldest_e2e_ruby, env: {}, dir: dir, gemfile: "#{dir}/Gemfile")
end
end

# Resolve a --cell path against the apps. Their lock is <dir>/Gemfile.lock, so
# there's no gemfiles/<base> segment for cell_path_parts to key off.
def app_cell_for(path)
dir = path.delete_prefix("./").sub(%r{/Gemfile(\.lock)?\z}, "")
app_cells.find { |cell| cell.dir == dir }
end

def mise_bin
@mise_bin ||= begin
found = `sh -lc 'command -v mise' 2>/dev/null`.strip
Expand Down
44 changes: 31 additions & 13 deletions bin/relock
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@
# 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.
#
# The e2e apps under `spec/apps` are covered too. They are ordinary Bundler
# projects — a committed Gemfile, no matrix axes, one lock each — so there is no
# wrapper to generate; only the lock is regenerated. One lock serves every Ruby
# in the e2e job matrix, so each app resolves under the oldest of them.
#
# 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.ci.toml; install them once with `mise --env ci install`. This
# script resolves against those already-installed Rubies and aborts if any are
# missing.
#
# bin/relock # refresh every cell
# bin/relock # refresh every cell, apps included
# bin/relock --gem sentry-ruby # one gem
# bin/relock --app rails-mini # one e2e app
# bin/relock --cell sentry-ruby/gemfiles/ruby-3.2_rack-3_redis-5.gemfile
# bin/relock --cell spec/apps/rails-mini/Gemfile
# bin/relock --force # delete each lock before resolving
# bin/relock -l # list every cell's lock path
#
Expand All @@ -34,14 +41,19 @@ require "optparse"
require_relative "lib/matrix"
include Matrix

# Shell run under the cell's Ruby. Writes the wrapper, re-resolves, adds
# checksums, and seeds x86_64-linux-gnu into PLATFORMS.
# Shell run under the cell's Ruby. Re-resolves, adds checksums, and seeds
# x86_64-linux-gnu into PLATFORMS.
#
# WRAPPER is set only for per-matrix cells; an app's Gemfile is committed rather
# than generated, so writing one would clobber it.
#
# When FORCE is set, the existing lock is deleted first so bundler resolves from scratch, use for edge cases.
RESOLVE = <<~SH
set -euo pipefail
mkdir -p "$(dirname "$BUNDLE_GEMFILE")"
echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE"
if [ -n "${WRAPPER:-}" ]; then
echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE"
fi
if [ -n "${FORCE:-}" ]; then
echo "+ FORCE: removing $BUNDLE_GEMFILE.lock"
rm -f "$BUNDLE_GEMFILE.lock"
Expand All @@ -63,6 +75,7 @@ def run_mise(cell, force: false)
# re-source the profile and reset Ruby back to the host default.
argv = [mise_bin, "exec", "ruby@#{cell.ruby}", "--", "bash", "-c", RESOLVE]
env = cell_env(cell)
env = env.merge("WRAPPER" => "1") if cell.generated_wrapper?
env = env.merge("FORCE" => "1") if force
env = env.merge("JRUBY" => "1") if cell.ruby.start_with?("jruby")
[env, argv]
Expand All @@ -72,6 +85,7 @@ end

opts = {
gems: [],
apps: [],
cell: nil,
list: false,
force: false
Expand All @@ -80,6 +94,7 @@ opts = {
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("--app NAME", "Only one e2e app (repeatable). Default: all.") { |v| opts[:apps] << 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 }
Expand All @@ -90,13 +105,16 @@ parser.parse!(ARGV)
# ---- select cells --------------------------------------------------------

if opts[:cell]
# single explicit cell
gem, base = cell_path_parts(opts[:cell])
abort "--cell must point at a <gem>/gemfiles/<cell>.gemfile path" unless gem && base
cells = [parse_cell(gem, base)]
cells = [app_cell_for(opts[:cell])].compact
if cells.empty?
gem, base = cell_path_parts(opts[:cell])
abort "--cell must point at a <gem>/gemfiles/<cell>.gemfile or <app>/Gemfile path" unless gem && base
cells = [parse_cell(gem, base)]
end
elsif opts[:gems].empty? && opts[:apps].empty?
cells = discover_cells(all_gems) + app_cells
else
gems = opts[:gems].empty? ? all_gems : opts[:gems]
cells = discover_cells(gems)
cells = discover_cells(opts[:gems]) + app_cells(opts[:apps])
end

if cells.empty?
Expand Down Expand Up @@ -152,9 +170,9 @@ cells.each_with_index do |cell, i|

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))
# chdir into the gem/app dir to match CI's working-directory (avoids stray
# files and keeps any .bundle/ config local to it).
ok = system(env, *argv, chdir: File.join(ROOT, cell.dir))
elapsed = fmt_duration(Time.now - cell_started)

if ok
Expand Down
4 changes: 2 additions & 2 deletions spec/apps/rails-mini/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ gem 'activerecord'
gem 'activejob'
gem 'sqlite3'

gem 'sentry-ruby', path: Pathname(__dir__).join("../../..").realpath
gem 'sentry-rails', path: Pathname(__dir__).join("../../..").realpath
gem 'sentry-ruby', path: '../../..'
gem 'sentry-rails', path: '../../..'
gem 'sidekiq'
gem 'resque'
gem 'delayed_job_active_record'
Expand Down
Loading
Loading