Skip to content

Commit b1b772b

Browse files
justin808claude
andauthored
Pin npm shakapacker version to match gem in example generation (#2558)
## Summary - Fix CI example generation failure when a new shakapacker version is published to npm before/independently of the gem - `shakapacker:install` adds the npm package with a caret range (e.g., `^9.5.0`), so `npm install` can resolve to a newer minor version (e.g., 9.6.0) while the gem remains at 9.5.0 - Added `pin_shakapacker_npm_version` that reads the exact gem version from the example app's `Gemfile.lock` and pins the npm `package.json` entry to match Fixes the `examples (3.4, latest)` CI failure seen on PR #2516 where npm shakapacker resolved to 9.6.0 while the gem was 9.5.0. ## Test plan - [ ] CI `examples (3.4, latest)` job passes - [ ] Verify that generated example apps have matching gem and npm shakapacker versions 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Small, localized change to example-generation rake tasks that only adjusts dependency pinning and should not affect runtime application behavior. > > **Overview** > Prevents CI/example app generation failures caused by `shakapacker` gem/npm version drift by **pinning the npm `shakapacker` dependency to the exact gem version** after `shakapacker:install`. > > Adds helpers to read the installed Shakapacker gem version from `Gemfile.lock` and rewrite `package.json` when it contains a semver range (e.g., `^X.Y.Z`) before running `npm install` for non-react-pinned examples. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 39fbbb6. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automatically syncs the npm Shakapacker dependency to the installed gem version during example app generation to prevent mismatches. * **Bug Fixes** * Improved error handling when reading package metadata and only rewrites files when changes occur. * **Documentation** * Added CONTRIBUTING guidance on pinning and updating the Shakapacker version and regenerating lockfiles. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f2a0ab9 commit b1b772b

3 files changed

Lines changed: 182 additions & 3 deletions

File tree

CONTRIBUTING.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,31 @@ Run `rake -T` or `rake -D` to see testing options.
440440

441441
See below for verifying changes to the generators.
442442

443+
## Updating the Shakapacker Version for Testing
444+
445+
When a new version of Shakapacker is released, the pinned version used across the test suite needs to be updated. The version is specified in several places:
446+
447+
**Source files (update manually):**
448+
449+
1. `react_on_rails/Gemfile.development_dependencies` — gem version pin
450+
2. `react_on_rails/spec/dummy/package.json` — npm version pin
451+
3. `react_on_rails_pro/Gemfile.development_dependencies` — gem version pin (Pro)
452+
4. `react_on_rails_pro/spec/dummy/package.json` — npm version pin (Pro)
453+
5. `react_on_rails_pro/spec/execjs-compatible-dummy/Gemfile` — gem version pin (Pro)
454+
6. `react_on_rails_pro/spec/execjs-compatible-dummy/package.json` — npm version pin (Pro)
455+
456+
**Lock files (regenerated automatically):**
457+
458+
After updating the source files above, regenerate lock files by running `bundle install` and `pnpm install` in the relevant directories:
459+
460+
- `react_on_rails/` and `react_on_rails/spec/dummy/` (OSS)
461+
- `react_on_rails_pro/` and `react_on_rails_pro/spec/dummy/` and `react_on_rails_pro/spec/execjs-compatible-dummy/` (Pro)
462+
- Root `Gemfile.lock` and `pnpm-lock.yaml`
463+
464+
**Example apps (handled automatically):**
465+
466+
The CI-generated example apps (under `gen-examples/`) automatically resolve the shakapacker version via the gem dependency. The `pin_shakapacker_npm_version` helper in `react_on_rails/rakelib/shakapacker_examples.rake` ensures the npm version matches the gem.
467+
443468
## CI Testing and Optimization
444469

445470
React on Rails uses an optimized CI pipeline that runs faster on branches while maintaining full coverage on `master`. Contributors have access to local CI tools to validate changes before pushing.

react_on_rails/rakelib/shakapacker_examples.rake

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,45 @@ require_relative "task_helpers"
1616
namespace :shakapacker_examples do # rubocop:disable Metrics/BlockLength
1717
include ReactOnRails::TaskHelpers
1818

19+
# Pins the shakapacker npm package version to exactly match the installed gem version.
20+
# Prevents semver range resolution (e.g., ^9.5.0 -> 9.6.0) from causing version mismatches.
21+
def pin_shakapacker_npm_version(dir) # rubocop:disable Metrics/CyclomaticComplexity
22+
gem_version = shakapacker_gem_version_from_lockfile(dir)
23+
return unless gem_version
24+
25+
package_json_path = File.join(dir, "package.json")
26+
return unless File.exist?(package_json_path)
27+
28+
begin
29+
package_json = JSON.parse(File.read(package_json_path))
30+
rescue JSON::ParserError => e
31+
puts " ERROR: Failed to parse #{package_json_path}: #{e.message}"
32+
raise
33+
end
34+
35+
changed = false
36+
%w[dependencies devDependencies].each do |section|
37+
deps = package_json[section]
38+
next unless deps&.key?("shakapacker")
39+
next if deps["shakapacker"] == gem_version
40+
41+
puts " Pinning npm shakapacker in #{section} from #{deps['shakapacker']} to exact #{gem_version}"
42+
deps["shakapacker"] = gem_version
43+
changed = true
44+
end
45+
46+
File.write(package_json_path, "#{JSON.pretty_generate(package_json)}\n") if changed
47+
end
48+
49+
# Reads the shakapacker gem version from the example app's Gemfile.lock
50+
def shakapacker_gem_version_from_lockfile(dir)
51+
lockfile = File.join(dir, "Gemfile.lock")
52+
return unless File.exist?(lockfile)
53+
54+
match = File.read(lockfile).match(/^\s+shakapacker\s+\(([^)]+)\)/)
55+
match&.[](1)
56+
end
57+
1958
# Updates React-related dependencies to a specific version
2059
def update_react_dependencies(deps, react_version)
2160
return unless deps
@@ -110,9 +149,11 @@ namespace :shakapacker_examples do # rubocop:disable Metrics/BlockLength
110149
apply_react_version(example_type.dir, example_type.react_version_string)
111150
# Re-run bundle install to ensure dependencies are resolved correctly
112151
bundle_install_in(example_type.dir)
113-
# Run npm install BEFORE shakapacker:binstubs to ensure the npm shakapacker version
114-
# matches the gem version. The binstubs task loads the Rails environment which
115-
# validates version matching between gem and npm package.
152+
# Pin the npm shakapacker version to exactly match the installed gem version.
153+
# shakapacker:install may add "^X.Y.Z" to package.json, which allows npm to
154+
# resolve a newer minor version (e.g., 9.6.0 when gem is 9.5.0), causing
155+
# Shakapacker's gem/npm version consistency check to fail.
156+
pin_shakapacker_npm_version(example_type.dir)
116157
# Use --legacy-peer-deps to avoid peer dependency conflicts when
117158
# react-on-rails expects newer React versions
118159
# Use --install-links to copy file: dependencies instead of symlinking,
@@ -122,6 +163,11 @@ namespace :shakapacker_examples do # rubocop:disable Metrics/BlockLength
122163
# The binstub format may differ between major versions
123164
unbundled_sh_in_dir(example_type.dir, "bundle exec rake shakapacker:binstubs")
124165
else
166+
# Pin the npm shakapacker version to exactly match the installed gem version.
167+
# shakapacker:install may add "^X.Y.Z" to package.json, which allows npm to
168+
# resolve a newer minor version (e.g., 9.6.0 when gem is 9.5.0), causing
169+
# Shakapacker's gem/npm version consistency check to fail.
170+
pin_shakapacker_npm_version(example_type.dir)
125171
# Use --install-links to copy file: dependencies instead of symlinking,
126172
# preventing duplicate React instances from webpack resolving through symlinks
127173
sh_in_dir(example_type.dir, "npm install --install-links")
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "spec_helper"
4+
require_relative "../../rakelib/example_type"
5+
require "json"
6+
require "rake"
7+
require "tmpdir"
8+
9+
RSpec.describe "shakapacker_examples rake helpers" do
10+
let(:rake_file) { File.expand_path("../../rakelib/shakapacker_examples.rake", __dir__) }
11+
let(:task_context) { TOPLEVEL_BINDING.eval("self") }
12+
13+
describe "#pin_shakapacker_npm_version" do
14+
before do
15+
Rake::Task.clear
16+
allow(ReactOnRails::TaskHelpers::ExampleType).to receive(:all).and_return({ shakapacker_examples: [] })
17+
load rake_file
18+
end
19+
20+
it "pins shakapacker in both dependency sections" do
21+
Dir.mktmpdir do |dir|
22+
File.write(File.join(dir, "Gemfile.lock"), " shakapacker (9.5.0)\n")
23+
File.write(
24+
File.join(dir, "package.json"),
25+
<<~JSON
26+
{
27+
"dependencies": {
28+
"shakapacker": "^9.5.0"
29+
},
30+
"devDependencies": {
31+
"shakapacker": "~9.5.0"
32+
}
33+
}
34+
JSON
35+
)
36+
37+
task_context.send(:pin_shakapacker_npm_version, dir)
38+
39+
package_json = JSON.parse(File.read(File.join(dir, "package.json")))
40+
expect(package_json.dig("dependencies", "shakapacker")).to eq("9.5.0")
41+
expect(package_json.dig("devDependencies", "shakapacker")).to eq("9.5.0")
42+
end
43+
end
44+
45+
it "reads prerelease shakapacker gem versions from Gemfile.lock" do
46+
Dir.mktmpdir do |dir|
47+
File.write(File.join(dir, "Gemfile.lock"), " shakapacker (9.6.0.beta.0)\n")
48+
49+
gem_version = task_context.send(:shakapacker_gem_version_from_lockfile, dir)
50+
51+
expect(gem_version).to eq("9.6.0.beta.0")
52+
end
53+
end
54+
end
55+
56+
describe "pinned React example generation" do
57+
let(:example_dir) { "/tmp/example-app" }
58+
let(:example_type) do
59+
instance_double(
60+
ReactOnRails::TaskHelpers::ExampleType,
61+
dir: example_dir,
62+
name: "example-app",
63+
name_pretty: "example-app example app",
64+
rails_options: "--skip-bundle",
65+
gemfile: "#{example_dir}/Gemfile",
66+
generator_shell_commands: ["rails generate react_on_rails:install"],
67+
pinned_react_version?: true,
68+
react_version_string: "18.0.0",
69+
clobber_task_name_short: "clobber_example_app",
70+
clobber_task_name: "shakapacker_examples:clobber_example_app",
71+
gen_task_name_short: "gen_example_app",
72+
gen_task_name: "shakapacker_examples:gen_example_app"
73+
)
74+
end
75+
76+
before do
77+
Rake::Task.clear
78+
allow(ReactOnRails::TaskHelpers::ExampleType).to receive(:all).and_return(
79+
{ shakapacker_examples: [example_type] }
80+
)
81+
load rake_file
82+
83+
allow(task_context).to receive(:puts)
84+
allow(task_context).to receive(:mkdir_p)
85+
allow(task_context).to receive(:rm_rf)
86+
allow(task_context).to receive(:sh_in_dir)
87+
allow(task_context).to receive(:unbundled_sh_in_dir)
88+
allow(task_context).to receive(:apply_react_version)
89+
end
90+
91+
it "pins shakapacker after the pinned React branch bundle install and before npm install" do
92+
bundle_install_calls = 0
93+
94+
allow(task_context).to receive(:bundle_install_in) do |_dir|
95+
bundle_install_calls += 1
96+
end
97+
98+
expect(task_context).to receive(:pin_shakapacker_npm_version).with(example_dir).ordered do
99+
expect(bundle_install_calls).to eq(3)
100+
end
101+
expect(task_context).to receive(:sh_in_dir)
102+
.with(example_dir, "npm install --legacy-peer-deps --install-links")
103+
.ordered
104+
105+
Rake::Task["shakapacker_examples:gen_example_app"].invoke
106+
end
107+
end
108+
end

0 commit comments

Comments
 (0)