Skip to content

Commit 0d87ea7

Browse files
Unify release scripts and add strict version validation (#1881)
Unify release scripts and add strict version validation (#1881) Why - Version mismatches between gem and npm packages caused subtle runtime errors that were difficult to diagnose. - Separate release scripts for Core and Pro packages created maintenance overhead and increased risk of version skew. - Permissive version checking (warnings only) allowed misconfigurations to reach production. Summary This PR consolidates release workflows into a single atomic process with synchronized versioning across all five packages (react-on-rails gem/npm, react-on-rails-pro gem/npm, and node-renderer). It replaces soft warnings with strict fail-fast validation at boot time and request time, enforcing exact version matching and preventing common misconfigurations. Key improvements - Unified release script manages all five packages atomically with single version number, automatic Ruby version switching, and semver bump support - Strict boot-time validation fails fast with actionable errors for missing package.json, conflicting packages, semver wildcards, or version mismatches - Node renderer validates gem version on every request (strict in dev, permissive with warnings in prod) with normalization handling Ruby vs NPM version format differences - Command injection protection via Shellwords escaping and input validation for all package manager commands - Cache size management prevents unbounded memory growth in version comparison - Improved wildcard and x-range detection in semver validation - Dynamic package manager detection provides manager-specific install/remove commands in error messages Breaking changes - Applications now fail to boot (instead of logging warnings) when package.json is misconfigured with wrong versions, missing packages, or semver wildcards. - Users must use exact versions in package.json (no ^, ~, >, <, * operators). - Remote node renderer validates gem version at request time; version mismatches in development now return 412 Precondition Failed (production allows with warning). Migration: Update package.json to use exact versions matching installed gem. Security - Added command injection protection via Shellwords.escape for package names and versions in all package manager command generation. - Input validation enforces npm naming standards for package names and safe semver patterns for versions. - Defense-in-depth: validation before command generation plus escaping. Impact - Existing installs: Boot-time validation will surface any existing misconfigurations immediately with clear remediation steps. Remote node renderer users may see 412 errors in development if versions are mismatched. - New installs: Prevented from launching with incorrect configurations; error messages guide to correct package.json setup. Upgrade/rollback notes Before upgrading: Ensure package.json uses exact versions (e.g., "16.1.1" not "^16.1.1") matching your installed gem version. For Pro users, ensure react-on-rails-pro package matches react_on_rails_pro gem version. To rollback after upgrade: If validation errors block your application, either fix package.json per error message or temporarily rollback gem version until package.json can be corrected. References - PR #1881 - Issue #1876
1 parent d527ddf commit 0d87ea7

File tree

26 files changed

+1664
-1483
lines changed

26 files changed

+1664
-1483
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Changes since the last non-beta release.
2727

2828
- **Attribution Comment**: Added HTML comment attribution to Rails views containing React on Rails functionality. The comment automatically displays which version is in use (open source React on Rails or React on Rails Pro) and, for Pro users, shows the license status. This helps identify React on Rails usage across your application. [PR #1857](https://github.com/shakacode/react_on_rails/pull/1857) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
2929

30+
- **Improved Error Messages**: Error messages for version mismatches and package configuration issues now include package-manager-specific installation commands (npm, yarn, pnpm, bun). [PR #1881](https://github.com/shakacode/react_on_rails/pull/1881) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
31+
3032
#### Breaking Changes
3133

3234
- **React on Rails Core Package**: Several Pro-only methods have been removed from the core package and are now exclusively available in the `react-on-rails-pro` package. If you're using any of the following methods, you'll need to migrate to React on Rails Pro:
@@ -106,6 +108,14 @@ To migrate to React on Rails Pro:
106108

107109
These helpers are now defined exclusively in the `react-on-rails-pro` gem.
108110

111+
- **Strict Version Validation at Boot Time**: Applications now fail to boot (instead of logging warnings) when package.json is misconfigured with wrong versions, missing packages, or semver wildcards. Users must use exact versions in package.json (no ^, ~, >, <, \* operators). **Migration**: Update package.json to use exact versions matching installed gem (e.g., `"16.1.1"` not `"^16.1.1"`). [PR #1881](https://github.com/shakacode/react_on_rails/pull/1881) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
112+
113+
- **Node Renderer Version Validation** (Pro users only): Remote node renderer now validates gem version at request time. Version mismatches in development return 412 Precondition Failed (production allows with warning). **Migration**: Ensure react_on_rails_pro gem and @shakacode-tools/react-on-rails-pro-node-renderer package versions match. [PR #1881](https://github.com/shakacode/react_on_rails/pull/1881) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
114+
115+
#### Security
116+
117+
- **Command Injection Protection**: Added security hardening to prevent potential command injection in package manager commands. [PR #1881](https://github.com/shakacode/react_on_rails/pull/1881) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
118+
109119
### [16.1.1] - 2025-09-24
110120

111121
#### Bug Fixes

docs/contributor-info/releasing.md

Lines changed: 126 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Install and Release
22

3-
We're releasing this as a combined Ruby gem plus two NPM packages. We keep the version numbers in sync across all packages.
3+
We're releasing this as a unified release with 5 packages total. We keep the version numbers in sync across all packages using unified versioning.
44

55
## Testing the Gem before Release from a Rails App
66

@@ -13,41 +13,79 @@ Run `rake -D release` to see instructions on how to release via the rake task.
1313
### Release Command
1414

1515
```bash
16-
rake release[gem_version,dry_run]
16+
rake release[version,dry_run,registry,skip_push]
1717
```
1818

1919
**Arguments:**
2020

21-
- `gem_version`: The new version in rubygem format (no dashes). Pass no argument to automatically perform a patch version bump.
22-
- `dry_run`: Optional. Pass `true` to see what would happen without actually releasing.
21+
1. **`version`** (required): Version bump type or explicit version
2322

24-
**Example:**
23+
- Bump types: `patch`, `minor`, `major`
24+
- Explicit: `16.2.0`
25+
- Pre-release: `16.2.0.beta.1` (rubygem format with dots, converted to `16.2.0-beta.1` for NPM)
26+
27+
2. **`dry_run`** (optional): `true` to preview changes without releasing
28+
29+
- Default: `false`
30+
31+
3. **`registry`** (optional): Publishing registry for testing
32+
33+
- `verdaccio`: Publish all NPM packages to local Verdaccio (skips RubyGems)
34+
- `npm`: Normal release to npmjs.org + rubygems.org (default)
35+
36+
4. **`skip_push`** (optional): Skip git push to remote
37+
- `skip_push`: Don't push commits/tags to remote
38+
- Default: pushes to remote
39+
40+
**Examples:**
2541

2642
```bash
27-
rake release[16.2.0] # Release version 16.2.0
28-
rake release[16.2.0,true] # Dry run to preview changes
29-
rake release # Auto-bump patch version
43+
rake release[patch] # Bump patch version (16.1.1 → 16.1.2)
44+
rake release[minor] # Bump minor version (16.1.1 → 16.2.0)
45+
rake release[major] # Bump major version (16.1.1 → 17.0.0)
46+
rake release[16.2.0] # Set explicit version
47+
rake release[16.2.0.beta.1] # Set pre-release version (→ 16.2.0-beta.1 for NPM)
48+
rake release[16.2.0,true] # Dry run to preview changes
49+
rake release[16.2.0,false,verdaccio] # Test with local Verdaccio
50+
rake release[patch,false,npm,skip_push] # Release but don't push to GitHub
3051
```
3152

3253
### What Gets Released
3354

34-
The release task publishes three packages with the same version number:
55+
The release task publishes 5 packages with unified versioning:
3556

36-
1. **react-on-rails** NPM package
37-
2. **react-on-rails-pro** NPM package
38-
3. **react_on_rails** Ruby gem
57+
**PUBLIC (npmjs.org + rubygems.org):**
58+
59+
1. **react-on-rails** - NPM package
60+
2. **react-on-rails-pro** - NPM package
61+
3. **react_on_rails** - RubyGem
62+
63+
**PRIVATE (GitHub Packages):** 4. **@shakacode-tools/react-on-rails-pro-node-renderer** - NPM package 5. **react_on_rails_pro** - RubyGem
3964

4065
### Version Synchronization
4166

4267
The task updates versions in all the following files:
4368

44-
- `lib/react_on_rails/version.rb` (source of truth)
69+
**Core package:**
70+
71+
- `lib/react_on_rails/version.rb` (source of truth for all packages)
4572
- `package.json` (root workspace)
4673
- `packages/react-on-rails/package.json`
47-
- `packages/react-on-rails-pro/package.json` (both version field and react-on-rails dependency)
74+
- `Gemfile.lock` (root)
4875
- `spec/dummy/Gemfile.lock`
4976

50-
**Note:** The `react-on-rails-pro` package declares an exact version dependency on `react-on-rails` (e.g., `"react-on-rails": "16.2.0"`). This ensures users install compatible versions of both packages.
77+
**Pro package:**
78+
79+
- `react_on_rails_pro/lib/react_on_rails_pro/version.rb` (VERSION only, not PROTOCOL_VERSION)
80+
- `react_on_rails_pro/package.json` (node-renderer)
81+
- `packages/react-on-rails-pro/package.json` (+ dependency version)
82+
- `react_on_rails_pro/Gemfile.lock`
83+
- `react_on_rails_pro/spec/dummy/Gemfile.lock`
84+
85+
**Note:**
86+
87+
- `react_on_rails_pro.gemspec` dynamically references `ReactOnRails::VERSION`
88+
- `react-on-rails-pro` NPM dependency is pinned to exact version (e.g., `"react-on-rails": "16.2.0"`)
5189

5290
### Pre-release Versions
5391

@@ -107,14 +145,85 @@ After a successful release, you'll see instructions to:
107145

108146
## Requirements
109147

110-
This task depends on the `gem-release` Ruby gem, which is installed via `bundle install`.
148+
### NPM Publishing
149+
150+
You must be logged in and have publish permissions:
111151

112-
For NPM publishing, you must be logged in to npm and have publish permissions for both packages:
152+
**For public packages (npmjs.org):**
113153

114154
```bash
115155
npm login
116156
```
117157

158+
**For private packages (GitHub Packages):**
159+
160+
- Get a GitHub personal access token with `write:packages` scope
161+
- Add to `~/.npmrc`:
162+
```ini
163+
//npm.pkg.github.com/:_authToken=<TOKEN>
164+
always-auth=true
165+
```
166+
- Set environment variable:
167+
```bash
168+
export GITHUB_TOKEN=<TOKEN>
169+
```
170+
171+
### RubyGems Publishing
172+
173+
**For public gem (rubygems.org):**
174+
175+
- Standard RubyGems credentials via `gem push`
176+
177+
**For private gem (GitHub Packages):**
178+
179+
- Add to `~/.gem/credentials`:
180+
```
181+
:github: Bearer <GITHUB_TOKEN>
182+
```
183+
184+
### Ruby Version Management
185+
186+
The script automatically detects and switches Ruby versions when needed:
187+
188+
- Supports: RVM, rbenv, asdf
189+
- Set via `RUBY_VERSION_MANAGER` environment variable (default: `rvm`)
190+
- Example: Pro dummy app requires Ruby 3.3.7, script auto-switches from 3.3.0
191+
192+
### Dependencies
193+
194+
This task depends on the `gem-release` Ruby gem, which is installed via `bundle install`.
195+
196+
## Testing with Verdaccio
197+
198+
Before releasing to production, test the release process locally:
199+
200+
1. Install and start Verdaccio:
201+
202+
```bash
203+
npm install -g verdaccio
204+
verdaccio
205+
```
206+
207+
2. Run release with verdaccio registry:
208+
209+
```bash
210+
rake release[patch,false,verdaccio]
211+
```
212+
213+
3. This will:
214+
215+
- Publish all 3 NPM packages to local Verdaccio
216+
- Skip RubyGem publishing
217+
- Update version files (revert manually after testing)
218+
219+
4. Test installing from Verdaccio:
220+
```bash
221+
npm set registry http://localhost:4873/
222+
npm install react-on-rails@16.2.0
223+
# Reset when done:
224+
npm config delete registry
225+
```
226+
118227
## Troubleshooting
119228

120229
### Dry Run First

lib/react_on_rails/engine.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,17 @@
44

55
module ReactOnRails
66
class Engine < ::Rails::Engine
7+
# Validate package versions and compatibility on Rails startup
8+
# This ensures the application fails fast if versions don't match or packages are misconfigured
9+
initializer "react_on_rails.validate_version_and_package_compatibility" do
10+
config.after_initialize do
11+
Rails.logger.info "[React on Rails] Validating package version and compatibility..."
12+
VersionChecker.build.validate_version_and_package_compatibility!
13+
Rails.logger.info "[React on Rails] Package validation successful"
14+
end
15+
end
16+
717
config.to_prepare do
8-
VersionChecker.build.log_if_gem_and_node_package_versions_differ
918
ReactOnRails::ServerRenderingPool.reset_pool
1019
end
1120

lib/react_on_rails/utils.rb

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "rainbow"
66
require "active_support"
77
require "active_support/core_ext/string"
8+
require "shellwords"
89

910
# rubocop:disable Metrics/ModuleLength
1011
module ReactOnRails
@@ -283,6 +284,125 @@ def self.prepend_to_file_if_text_not_present(file:, text_to_prepend:, regex:)
283284
puts "Prepended\n#{text_to_prepend}to #{file}."
284285
end
285286

287+
# Detects which package manager is being used.
288+
# First checks the packageManager field in package.json (Node.js Corepack standard),
289+
# then falls back to checking for lock files.
290+
#
291+
# @return [Symbol] The package manager symbol (:npm, :yarn, :pnpm, :bun)
292+
def self.detect_package_manager
293+
manager = detect_package_manager_from_package_json || detect_package_manager_from_lock_files
294+
manager || :yarn # Default to yarn if no detection succeeds
295+
end
296+
297+
# Validates package_name input to prevent command injection
298+
#
299+
# @param package_name [String] The package name to validate
300+
# @raise [ReactOnRails::Error] if package_name contains potentially unsafe characters
301+
private_class_method def self.validate_package_name!(package_name)
302+
raise ReactOnRails::Error, "package_name cannot be nil" if package_name.nil?
303+
raise ReactOnRails::Error, "package_name cannot be empty" if package_name.to_s.strip.empty?
304+
305+
# Allow valid npm package names: alphanumeric, hyphens, underscores, dots, slashes (for scoped packages)
306+
# See: https://github.com/npm/validate-npm-package-name
307+
return if package_name.match?(%r{\A[@a-z0-9][a-z0-9._/-]*\z}i)
308+
309+
raise ReactOnRails::Error, "Invalid package name: #{package_name.inspect}. " \
310+
"Package names must contain only alphanumeric characters, " \
311+
"hyphens, underscores, dots, and slashes (for scoped packages)."
312+
end
313+
314+
# Validates package_name and version inputs to prevent command injection
315+
#
316+
# @param package_name [String] The package name to validate
317+
# @param version [String] The version to validate
318+
# @raise [ReactOnRails::Error] if inputs contain potentially unsafe characters
319+
private_class_method def self.validate_package_command_inputs!(package_name, version)
320+
validate_package_name!(package_name)
321+
322+
raise ReactOnRails::Error, "version cannot be nil" if version.nil?
323+
raise ReactOnRails::Error, "version cannot be empty" if version.to_s.strip.empty?
324+
325+
# Allow valid semver versions and common npm version patterns
326+
# This allows: 1.2.3, 1.2.3-beta.1, 1.2.3-alpha, etc.
327+
return if version.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
328+
329+
raise ReactOnRails::Error, "Invalid version: #{version.inspect}. " \
330+
"Versions must contain only alphanumeric characters, dots, hyphens, and underscores."
331+
end
332+
333+
private_class_method def self.detect_package_manager_from_package_json
334+
package_json_path = File.join(Rails.root, ReactOnRails.configuration.node_modules_location, "package.json")
335+
return nil unless File.exist?(package_json_path)
336+
337+
package_json_data = JSON.parse(File.read(package_json_path))
338+
return nil unless package_json_data["packageManager"]
339+
340+
manager_string = package_json_data["packageManager"]
341+
# Extract manager name from strings like "yarn@3.6.0" or "pnpm@8.0.0"
342+
manager_name = manager_string.split("@").first
343+
manager_name.to_sym if %w[npm yarn pnpm bun].include?(manager_name)
344+
rescue StandardError
345+
nil
346+
end
347+
348+
private_class_method def self.detect_package_manager_from_lock_files
349+
root = Rails.root
350+
return :yarn if File.exist?(File.join(root, "yarn.lock"))
351+
return :pnpm if File.exist?(File.join(root, "pnpm-lock.yaml"))
352+
return :bun if File.exist?(File.join(root, "bun.lockb"))
353+
return :npm if File.exist?(File.join(root, "package-lock.json"))
354+
355+
nil
356+
end
357+
358+
# Returns the appropriate install command for the detected package manager.
359+
# Generates the correct command with exact version syntax.
360+
#
361+
# @param package_name [String] The name of the package to install
362+
# @param version [String] The exact version to install
363+
# @return [String] The command to run (e.g., "yarn add react-on-rails@16.0.0 --exact")
364+
def self.package_manager_install_exact_command(package_name, version)
365+
validate_package_command_inputs!(package_name, version)
366+
367+
manager = detect_package_manager
368+
# Escape shell arguments to prevent command injection
369+
safe_package = Shellwords.escape("#{package_name}@#{version}")
370+
371+
case manager
372+
when :pnpm
373+
"pnpm add #{safe_package} --save-exact"
374+
when :bun
375+
"bun add #{safe_package} --exact"
376+
when :npm
377+
"npm install #{safe_package} --save-exact"
378+
else # :yarn or unknown, default to yarn
379+
"yarn add #{safe_package} --exact"
380+
end
381+
end
382+
383+
# Returns the appropriate remove command for the detected package manager.
384+
#
385+
# @param package_name [String] The name of the package to remove
386+
# @return [String] The command to run (e.g., "yarn remove react-on-rails")
387+
def self.package_manager_remove_command(package_name)
388+
validate_package_name!(package_name)
389+
390+
manager = detect_package_manager
391+
# Escape shell arguments to prevent command injection
392+
safe_package = Shellwords.escape(package_name)
393+
394+
case manager
395+
when :pnpm
396+
"pnpm remove #{safe_package}"
397+
when :bun
398+
"bun remove #{safe_package}"
399+
when :npm
400+
"npm uninstall #{safe_package}"
401+
else # :yarn or unknown, default to yarn
402+
"yarn remove #{safe_package}"
403+
end
404+
end
405+
286406
def self.default_troubleshooting_section
287407
<<~DEFAULT
288408
📞 Get Help & Support:

0 commit comments

Comments
 (0)