Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f92a09e
Create unified release script with synchronized versioning
AbanoubGhadban Oct 26, 2025
4545ce7
Add automatic Ruby version detection and switching for bundle install
AbanoubGhadban Oct 26, 2025
475eab5
Fix Ruby version switching to work in same shell context
AbanoubGhadban Oct 26, 2025
8615077
tmp
AbanoubGhadban Oct 26, 2025
f55c485
tmp
AbanoubGhadban Oct 26, 2025
f5f9c88
Revert "tmp"
AbanoubGhadban Oct 26, 2025
a207616
Add automatic Ruby version detection and switching to release script
AbanoubGhadban Oct 26, 2025
09e11c1
Fix dependency name for react-on-rails-pro package in release script
AbanoubGhadban Oct 26, 2025
9d35877
Fix regex to only update VERSION, not PROTOCOL_VERSION
AbanoubGhadban Oct 26, 2025
c9e9ca0
Update release documentation and remove deprecated pro-specific relea…
AbanoubGhadban Oct 26, 2025
2a7e2d5
Skip git pull when skip_push is set
AbanoubGhadban Oct 26, 2025
18ff99c
tmp
AbanoubGhadban Oct 26, 2025
aa01b22
Revert "tmp"
AbanoubGhadban Oct 26, 2025
2a6a945
Update release commands to version 17.0.0 in documentation
AbanoubGhadban Oct 26, 2025
661312e
update lock files
AbanoubGhadban Oct 26, 2025
bf5681a
Add strict version validation for React on Rails packages
AbanoubGhadban Oct 26, 2025
9c73553
Update version to 16.1.1 in version.rb, package.json, and Gemfile.lock
AbanoubGhadban Oct 26, 2025
e04c313
Add gem version validation between Ruby gem and Node renderer
AbanoubGhadban Oct 26, 2025
730798c
Add caching for gem version comparison to improve performance
AbanoubGhadban Oct 26, 2025
4f18aa5
linting
AbanoubGhadban Oct 26, 2025
230d190
Add dynamic package manager detection for error messages
AbanoubGhadban Oct 27, 2025
f8fbc5f
Update release documentation to include pre-release version format
AbanoubGhadban Oct 27, 2025
9a0523d
Fix CI failures and add missing package validation
AbanoubGhadban Oct 27, 2025
2395751
Refactor validate_package_gem_compatibility! to fix RuboCop metrics
AbanoubGhadban Oct 27, 2025
d7523d5
Enhance version validation by adding checks for special version strin…
AbanoubGhadban Oct 27, 2025
a6b3636
Add security hardening to prevent command injection in package manage…
AbanoubGhadban Oct 27, 2025
9a90b0c
Improve wildcard and x-range detection in semver validation
AbanoubGhadban Oct 27, 2025
0d235b3
linting
AbanoubGhadban Oct 27, 2025
d16d32e
Implement cache size management in version comparison to prevent unbo…
AbanoubGhadban Oct 27, 2025
85cbb19
Update CHANGELOG.md to document new features and improvements, includ…
AbanoubGhadban Oct 27, 2025
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
143 changes: 126 additions & 17 deletions docs/contributor-info/releasing.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Install and Release

We're releasing this as a combined Ruby gem plus two NPM packages. We keep the version numbers in sync across all packages.
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.
Copy link
Collaborator

@alexeyr-ci2 alexeyr-ci2 Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like "using unified versioning" means something in addition to "We keep the version numbers in sync across all packages" but if so it isn't clear what.


## Testing the Gem before Release from a Rails App

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

```bash
rake release[gem_version,dry_run]
rake release[version,dry_run,registry,skip_push]
```

**Arguments:**

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

**Example:**
- Bump types: `patch`, `minor`, `major`
- Explicit: `16.2.0`
- Pre-release: `16.2.0.beta.1` (rubygem format with dots, converted to `16.2.0-beta.1` for NPM)

2. **`dry_run`** (optional): `true` to preview changes without releasing

- Default: `false`

3. **`registry`** (optional): Publishing registry for testing

- `verdaccio`: Publish all NPM packages to local Verdaccio (skips RubyGems)
- `npm`: Normal release to npmjs.org + rubygems.org (default)

4. **`skip_push`** (optional): Skip git push to remote
- `skip_push`: Don't push commits/tags to remote
- Default: pushes to remote

**Examples:**

```bash
rake release[16.2.0] # Release version 16.2.0
rake release[16.2.0,true] # Dry run to preview changes
rake release # Auto-bump patch version
rake release[patch] # Bump patch version (16.1.1 → 16.1.2)
rake release[minor] # Bump minor version (16.1.1 → 16.2.0)
rake release[major] # Bump major version (16.1.1 → 17.0.0)
rake release[16.2.0] # Set explicit version
rake release[16.2.0.beta.1] # Set pre-release version (→ 16.2.0-beta.1 for NPM)
rake release[16.2.0,true] # Dry run to preview changes
rake release[16.2.0,false,verdaccio] # Test with local Verdaccio
rake release[patch,false,npm,skip_push] # Release but don't push to GitHub
```

### What Gets Released

The release task publishes three packages with the same version number:
The release task publishes 5 packages with unified versioning:

1. **react-on-rails** NPM package
2. **react-on-rails-pro** NPM package
3. **react_on_rails** Ruby gem
**PUBLIC (npmjs.org + rubygems.org):**

1. **react-on-rails** - NPM package
2. **react-on-rails-pro** - NPM package
3. **react_on_rails** - RubyGem

**PRIVATE (GitHub Packages):** 4. **@shakacode-tools/react-on-rails-pro-node-renderer** - NPM package 5. **react_on_rails_pro** - RubyGem

### Version Synchronization

The task updates versions in all the following files:

- `lib/react_on_rails/version.rb` (source of truth)
**Core package:**

- `lib/react_on_rails/version.rb` (source of truth for all packages)
- `package.json` (root workspace)
- `packages/react-on-rails/package.json`
- `packages/react-on-rails-pro/package.json` (both version field and react-on-rails dependency)
- `Gemfile.lock` (root)
- `spec/dummy/Gemfile.lock`

**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.
**Pro package:**

- `react_on_rails_pro/lib/react_on_rails_pro/version.rb` (VERSION only, not PROTOCOL_VERSION)
- `react_on_rails_pro/package.json` (node-renderer)
- `packages/react-on-rails-pro/package.json` (+ dependency version)
- `react_on_rails_pro/Gemfile.lock`
- `react_on_rails_pro/spec/dummy/Gemfile.lock`

**Note:**

- `react_on_rails_pro.gemspec` dynamically references `ReactOnRails::VERSION`
- `react-on-rails-pro` NPM dependency is pinned to exact version (e.g., `"react-on-rails": "16.2.0"`)

### Pre-release Versions

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

## Requirements

This task depends on the `gem-release` Ruby gem, which is installed via `bundle install`.
### NPM Publishing

You must be logged in and have publish permissions:

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

```bash
npm login
```

**For private packages (GitHub Packages):**

- Get a GitHub personal access token with `write:packages` scope
- Add to `~/.npmrc`:
```ini
//npm.pkg.github.com/:_authToken=<TOKEN>
always-auth=true
```
- Set environment variable:
```bash
export GITHUB_TOKEN=<TOKEN>
```

### RubyGems Publishing

**For public gem (rubygems.org):**

- Standard RubyGems credentials via `gem push`

**For private gem (GitHub Packages):**

- Add to `~/.gem/credentials`:
```
:github: Bearer <GITHUB_TOKEN>
```
Comment on lines +180 to +182
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language tag to fenced code block.

The code block at line 178 is missing a language identifier, causing linting failures (MD040).

Apply this diff:

-  ```
+  ```yaml
   :github: Bearer <GITHUB_TOKEN>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.18.1)</summary>

178-178: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

In docs/contributor-info/releasing.md around lines 178 to 180, the fenced code
block is missing a language tag which triggers MD040; update the opening
triple-backtick to include the yaml language identifier (```yaml) so the block
becomes a YAML fenced code block and linting will pass.


</details>

<!-- This is an auto-generated comment by CodeRabbit -->


### Ruby Version Management

The script automatically detects and switches Ruby versions when needed:

- Supports: RVM, rbenv, asdf
- Set via `RUBY_VERSION_MANAGER` environment variable (default: `rvm`)
- Example: Pro dummy app requires Ruby 3.3.7, script auto-switches from 3.3.0

### Dependencies

This task depends on the `gem-release` Ruby gem, which is installed via `bundle install`.

## Testing with Verdaccio

Before releasing to production, test the release process locally:

1. Install and start Verdaccio:

```bash
npm install -g verdaccio
verdaccio
```

2. Run release with verdaccio registry:

```bash
rake release[patch,false,verdaccio]
```

3. This will:

- Publish all 3 NPM packages to local Verdaccio
- Skip RubyGem publishing
- Update version files (revert manually after testing)

4. Test installing from Verdaccio:
```bash
npm set registry http://localhost:4873/
npm install react-on-rails@16.2.0
# Reset when done:
npm config delete registry
```

## Troubleshooting

### Dry Run First
Expand Down
11 changes: 10 additions & 1 deletion lib/react_on_rails/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,17 @@

module ReactOnRails
class Engine < ::Rails::Engine
# Validate package versions and compatibility on Rails startup
# This ensures the application fails fast if versions don't match or packages are misconfigured
initializer "react_on_rails.validate_version_and_package_compatibility" do
config.after_initialize do
Rails.logger.info "[React on Rails] Validating package version and compatibility..."
VersionChecker.build.validate_version_and_package_compatibility!
Rails.logger.info "[React on Rails] Package validation successful"
end
end

config.to_prepare do
VersionChecker.build.log_if_gem_and_node_package_versions_differ
ReactOnRails::ServerRenderingPool.reset_pool
end

Expand Down
120 changes: 120 additions & 0 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "rainbow"
require "active_support"
require "active_support/core_ext/string"
require "shellwords"

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

# Detects which package manager is being used.
# First checks the packageManager field in package.json (Node.js Corepack standard),
# then falls back to checking for lock files.
#
# @return [Symbol] The package manager symbol (:npm, :yarn, :pnpm, :bun)
def self.detect_package_manager
manager = detect_package_manager_from_package_json || detect_package_manager_from_lock_files
manager || :yarn # Default to yarn if no detection succeeds
end

# Validates package_name input to prevent command injection
#
# @param package_name [String] The package name to validate
# @raise [ReactOnRails::Error] if package_name contains potentially unsafe characters
private_class_method def self.validate_package_name!(package_name)
raise ReactOnRails::Error, "package_name cannot be nil" if package_name.nil?
raise ReactOnRails::Error, "package_name cannot be empty" if package_name.to_s.strip.empty?

# Allow valid npm package names: alphanumeric, hyphens, underscores, dots, slashes (for scoped packages)
# See: https://github.com/npm/validate-npm-package-name
return if package_name.match?(%r{\A[@a-z0-9][a-z0-9._/-]*\z}i)

raise ReactOnRails::Error, "Invalid package name: #{package_name.inspect}. " \
"Package names must contain only alphanumeric characters, " \
"hyphens, underscores, dots, and slashes (for scoped packages)."
end

# Validates package_name and version inputs to prevent command injection
#
# @param package_name [String] The package name to validate
# @param version [String] The version to validate
# @raise [ReactOnRails::Error] if inputs contain potentially unsafe characters
private_class_method def self.validate_package_command_inputs!(package_name, version)
validate_package_name!(package_name)

raise ReactOnRails::Error, "version cannot be nil" if version.nil?
raise ReactOnRails::Error, "version cannot be empty" if version.to_s.strip.empty?

# Allow valid semver versions and common npm version patterns
# This allows: 1.2.3, 1.2.3-beta.1, 1.2.3-alpha, etc.
return if version.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)

raise ReactOnRails::Error, "Invalid version: #{version.inspect}. " \
"Versions must contain only alphanumeric characters, dots, hyphens, and underscores."
end

private_class_method def self.detect_package_manager_from_package_json
package_json_path = File.join(Rails.root, ReactOnRails.configuration.node_modules_location, "package.json")
return nil unless File.exist?(package_json_path)

package_json_data = JSON.parse(File.read(package_json_path))
return nil unless package_json_data["packageManager"]

manager_string = package_json_data["packageManager"]
# Extract manager name from strings like "yarn@3.6.0" or "pnpm@8.0.0"
manager_name = manager_string.split("@").first
manager_name.to_sym if %w[npm yarn pnpm bun].include?(manager_name)
rescue StandardError
nil
end

private_class_method def self.detect_package_manager_from_lock_files
root = Rails.root
return :yarn if File.exist?(File.join(root, "yarn.lock"))
return :pnpm if File.exist?(File.join(root, "pnpm-lock.yaml"))
return :bun if File.exist?(File.join(root, "bun.lockb"))
return :npm if File.exist?(File.join(root, "package-lock.json"))

nil
end

# Returns the appropriate install command for the detected package manager.
# Generates the correct command with exact version syntax.
#
# @param package_name [String] The name of the package to install
# @param version [String] The exact version to install
# @return [String] The command to run (e.g., "yarn add react-on-rails@16.0.0 --exact")
def self.package_manager_install_exact_command(package_name, version)
validate_package_command_inputs!(package_name, version)

manager = detect_package_manager
# Escape shell arguments to prevent command injection
safe_package = Shellwords.escape("#{package_name}@#{version}")

case manager
when :pnpm
"pnpm add #{safe_package} --save-exact"
when :bun
"bun add #{safe_package} --exact"
when :npm
"npm install #{safe_package} --save-exact"
else # :yarn or unknown, default to yarn
"yarn add #{safe_package} --exact"
end
end

# Returns the appropriate remove command for the detected package manager.
#
# @param package_name [String] The name of the package to remove
# @return [String] The command to run (e.g., "yarn remove react-on-rails")
def self.package_manager_remove_command(package_name)
validate_package_name!(package_name)

manager = detect_package_manager
# Escape shell arguments to prevent command injection
safe_package = Shellwords.escape(package_name)

case manager
when :pnpm
"pnpm remove #{safe_package}"
when :bun
"bun remove #{safe_package}"
when :npm
"npm uninstall #{safe_package}"
else # :yarn or unknown, default to yarn
"yarn remove #{safe_package}"
end
end

def self.default_troubleshooting_section
<<~DEFAULT
📞 Get Help & Support:
Expand Down
Loading
Loading