Skip to content
Draft
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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ contradict any of them, stop and surface the conflict instead of shipping.
incompatible signatures. If the new API works on both versions (i.e. the old one
merely emits a deprecation warning), migrate the call site directly instead of
wrapping it in `NextRails.next?`. See `dual-boot/SKILL.md` "Pattern" section.
8. **Prefer a single backport/forwardport shim over scattered conditionals when the
version gap spans an application layer** (all controllers, all mailers, all models
with the same concern) **or affects roughly 20+ call sites**. A shim in one file
(`config/initializers/*.rb`, `test_helper.rb`, `spec_helper.rb`) keeps call sites
identical across both versions and collapses to a single-file delete at cleanup.
`NextRails.next?` call-site conditionals are fine for smaller gaps. See
`dual-boot/references/code-patterns.md` "Three-Tier Approach".

---

Expand Down
68 changes: 50 additions & 18 deletions dual-boot/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,29 @@ When code would break on one version and needs a different implementation on the

### Pattern

Reach for `NextRails.next?` only when the old and new APIs are genuinely **two-sided** — the old one raises on the next version and the new one doesn't exist on the current version. A plain deprecation warning is *not* a reason to branch: if the new API works on both sides, migrate the call site directly.
Every dual-boot has exactly one conditional that is always required: the Gemfile version pin. That is the foundational use of `next?`. App-code `NextRails.next?` is the exception, reserved for genuine two-sided breakage that cannot be resolved by migrating to a common API.

**The foundational conditional, the Gemfile version pin:**
```ruby
# Gemfile
if next?
gem 'rails', '~> 7.1.0'
else
gem 'rails', '~> 7.0.0'
end
```

The two pins genuinely fail resolution on opposite sides, so this conditional is required for bundler to resolve at all. Most dual-boots need nothing more than this plus a handful of gem-version pins for dependencies tied to the Rails version.

**Three-tier approach for application code.** When a version gap affects application code, pick the lowest tier that works:

1. **Tier 1: Unconditional migration (preferred).** Both versions accept the new form, so just use it everywhere. This covers the typical Rails deprecation case because Rails ships the replacement one version before it removes the old form.
2. **Tier 2: `NextRails.next?` conditional at the call site.** The new API doesn't exist on current, or the old one raises on next, and the affected call sites are few (up to ~20).
3. **Tier 3: Backport or forwardport shim.** The gap spans an application layer (all controllers, all mailers, all models with the same concern) or affects ~20+ call sites. A single monkey-patch in one initializer (for runtime gaps) or `test_helper.rb` / `spec_helper.rb` (for test-only gaps) keeps call sites identical on both sides and collapses to a single-file delete at cleanup.

See `references/code-patterns.md` "Three-Tier Approach" for the full decision framework, cleanup semantics per tier, and the Tier 3 `ActionController::Parameters#values` backport example.

**Tier 2 illustration, `ignored_columns` (Rails 4.2 → 5.0):**

❌ **WRONG — Do NOT use feature detection (`respond_to?`, `defined?`, etc.):**
```ruby
Expand Down Expand Up @@ -74,14 +96,17 @@ Put the next-version branch on top so cleanup is mechanical: after the upgrade,

### When to Apply

Use `NextRails.next?` branching for:
Most dual-boots only use `next?` in the Gemfile. App-code branching (Tier 2 or Tier 3) is reserved for genuine two-sided breakage where migrating to a common API (Tier 1) isn't possible. Reach for a call-site conditional or a shim when:

- **Removed methods or constants** where the replacement only exists on the new side (e.g., a gem-provided method replaced by a native Rails method with different syntax)
- **Incompatible signature or return-type changes** where the old and new forms genuinely fail on opposite sides
- **Gem version differences** where the gem's API is different across versions pinned to each Rails version
- **Initializer changes** where a config or middleware raises on one side
- **Ruby version differences** (e.g., syntax changes, stdlib removals)

**Not a reason to branch:** plain deprecation warnings. If the new API works on both versions (e.g. `config.fixture_path=` → `config.fixture_paths=`, `update_attributes` → `update` before its removal), migrate the call site directly — do not wrap it in `NextRails.next?`. See `references/code-patterns.md` "When NOT to Branch: Deprecations".
Whether to resolve the gap with a call-site conditional (Tier 2) or a single shim (Tier 3) depends on how many call sites are affected and whether the gap spans a whole application layer. See `references/code-patterns.md` "Three-Tier Approach".

**Not a reason to branch:** plain deprecation warnings. If the new API works on both versions (e.g. `config.fixture_path=` → `config.fixture_paths=`, `update_attributes` → `update` before its removal), migrate the call site directly. Do not wrap it in `NextRails.next?`. This is typical for Rails at adjacent-minor boundaries because Rails ships the replacement API one version before removing the old form. Non-adjacent hops or third-party gems may have more genuine two-sided breaks. See `references/code-patterns.md` "Tier 1: Unconditional Migration".

---

Expand Down Expand Up @@ -110,17 +135,24 @@ Claude should activate this skill when user says:
See `workflows/setup-workflow.md` for the complete step-by-step process.

**Summary:**
0. Verify deprecation warnings are not silenced (see `references/deprecation-tracking.md`)
1. Check if dual-boot is already set up (look for `Gemfile.next`)
2. Add `next_rails` gem to Gemfile
3. Run `bundle install`
4. Run `next_rails --init` (only if `Gemfile.next` does NOT exist)
5. Configure Gemfile with `next?` conditionals for the dependency being upgraded
6. Install dependencies for both versions:
- Current: `bundle install`
- Next: `cp Gemfile.lock Gemfile.next.lock && BUNDLE_GEMFILE=Gemfile.next bundle install`

7. Verify both versions work
*Phase 1, preparatory (before any Rails version hop):*
1. Verify deprecation warnings are not silenced (see `references/deprecation-tracking.md`)
2. Check if dual-boot is already set up (look for `Gemfile.next`)
3. Add `next_rails` gem at the Gemfile root and run `bundle install`
4. Run `next_rails --init` (only if `Gemfile.next` does NOT exist)
5. Configure `DeprecationTracker` (ask upfront whether CI runs tests in parallel)
6. Capture the deprecation inventory: `DEPRECATION_TRACKER=save bundle exec rspec` (substitute the project's test command: `bin/rails test`, `bin/test`, `bundle exec parallel_rspec spec/`, etc.)
7. Fix current-side deprecations unconditionally (Tier 1, no `NextRails.next?`)

*Phase 2, dual-boot (the version hop):*
8. Configure the Gemfile with the `if next?` version conditional
9. Identify and pin incompatible gems via `bundle_report compatibility --rails-version=<target>`
10. Install dependencies for both versions:
- Current: `bundle install`
- Next: `cp Gemfile.lock Gemfile.next.lock && BUNDLE_GEMFILE=Gemfile.next bundle install`
11. Verify both sides boot and run tests
12. Fix two-sided breakage using Tier 2 or Tier 3 (`references/code-patterns.md`)

### Workflow 2: Add Version-Dependent Code

Expand All @@ -143,13 +175,13 @@ See `workflows/cleanup-workflow.md` for the complete post-upgrade cleanup proces

**Summary:**
1. Search for all `NextRails.next?` references
2. Keep only the `NextRails.next?` (true) branch code
3. Remove all `else` branches
4. Remove `next?` method from Gemfile
2. At each call site, keep only the `NextRails.next?` (true) branch and remove the `else` branch (Tier 2 cleanup)
3. Delete backport/forwardport shim files whose outermost guard is `if NextRails.next?` or `if !NextRails.next?` (Tier 3 cleanup)
4. Remove `next?` method and `if next?` blocks from Gemfile
5. Remove `next_rails` gem if no longer needed
6. Remove `Gemfile.next` and replace `Gemfile.lock` with `Gemfile.next.lock`
7. Update CI to remove dual-boot configuration
8. Run full test suite
7. Run full test suite
8. Update CI to remove dual-boot configuration
9. Commit cleanup changes

---
Expand Down
28 changes: 28 additions & 0 deletions dual-boot/examples/basic-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ You have a Rails 4.2 application and want to upgrade to Rails 5.0. You want to s

---

## Before You Start: Resolve Current-Version Deprecations

Dual-boot is the workflow for handling genuine two-sided breakage between the current and next Rails versions. Before you get there, resolve the deprecation warnings your application already emits on its **current** Rails version (4.2 in this example). Those warnings are the roadmap for the hop: each one names an API that will fail on the next side. Fixing them up front turns most of the apparent "upgrade work" into routine current-version cleanup, and leaves only the genuinely two-sided problems for dual-boot to address.

Resolve current-version deprecations unconditionally (Tier 1 in the three-tier framework). Rails ships the replacement API one version before it removes the old form, so the new call almost always works on both the current and next sides. That means no `NextRails.next?` branch is needed, and wrapping a plain deprecation fix in a conditional produces a dead branch you will only have to clean up later. Save `NextRails.next?` for Step 6, where it addresses real two-sided breakage.

To get visibility into the full inventory of current-version deprecations and track them as you fix each one, see `references/deprecation-tracking.md` for `DeprecationTracker` setup. One sequencing note: configure `DeprecationTracker` **before** running the test suite for the first time. A single run then captures the complete set of warnings, avoiding a double pass just to build the inventory.

If you are following the broader Rails upgrade methodology from the rails-upgrade skill, current-version deprecation resolution is a prerequisite to the dual-boot hop, not a step inside it. The workflow below assumes that preparatory pass is done.

See `references/code-patterns.md` "Three-Tier Approach" for the full framing of Tier 1 (unconditional migration) versus Tier 2 and Tier 3.

---

## Step-by-Step

### 1. Add `next_rails` to Gemfile
Expand Down Expand Up @@ -57,6 +71,10 @@ BUNDLE_GEMFILE=Gemfile.next bundle install

### 5. Run Tests Against Both

This example uses RSpec. If your project uses Minitest, substitute `bin/rails test` (or `rake test`) for `bundle exec rspec` throughout. The `BUNDLE_GEMFILE=Gemfile.next` prefix works with any test runner. See `workflows/setup-workflow.md` Step 11 for the full detection logic (RSpec, Minitest, `bin/test`, `parallel_tests`, `turbo_tests`).

**RSpec:**

```bash
# Current version (4.2)
bundle exec rspec
Expand All @@ -67,6 +85,16 @@ BUNDLE_GEMFILE=Gemfile.next bundle exec rspec
# => Some failures — fix using NextRails.next? branching
```

**Minitest equivalent:**

```bash
# Current version (4.2)
bin/rails test

# Next version (5.0)
BUNDLE_GEMFILE=Gemfile.next bin/rails test
```

### 6. Fix a Breaking Change

On Rails 4.2, `ActionController::TestRequest.new` takes an optional `env`. On 5.0, `new` requires two non-optional arguments (so the 4.2 call raises `ArgumentError`), and 5.0 introduces `TestRequest.create` — which doesn't exist on 4.2 (`NoMethodError`). Each side raises on the other. A conditional is required:
Expand Down
96 changes: 93 additions & 3 deletions dual-boot/references/code-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The version numbers in the example below are from a Rails 4.2 → 5.0 upgrade, b

---

## Rails API Changes Requiring a Conditional
## The Foundational Case: Gemfile Version Pin

### `ActionController::TestRequest.new` → `.create` (Rails 4.2 → 5.0)

Expand All @@ -25,11 +25,32 @@ test_request =
end
```

This is genuinely two-sided — the `7.1.0` pin fails resolution on the 7.0 side and vice versa — so the conditional is required for bundler to resolve at all. Every other use of `NextRails.next?` is secondary (Tier 2 or 3).

---

## Three-Tier Approach

When a version gap affects application code (not the Gemfile), pick the **lowest tier that works**. Lower tiers are cheaper to read, cheaper to clean up, and do not leave dead branches behind. Climb tiers only when the lower option actually breaks.

| Tier | Technique | When |
|------|-----------|------|
| 1 | Unconditional migration — use the new API on both sides. | Both Rails versions accept the new form (the typical Rails deprecation case). |
| 2 | `NextRails.next?` conditional at the call site. | New API doesn't exist on current (or old API raises on next). Smaller gaps (up to ~20 call sites). |
| 3 | Backport / forwardport shim — monkey-patch in one file. | Same gap spans an application layer (all controllers, all mailers) or affects ~20+ call sites. |

**Cleanup at the end of the dual-boot is mechanical in all three tiers:**
- Tier 1 — nothing to clean; the code is already unconditional.
- Tier 2 — drop `else` branches, keep the `if NextRails.next?` body.
- Tier 3 — delete the shim file.

See `workflows/cleanup-workflow.md` for the full cleanup procedure.

---

## When NOT to Branch: Deprecations
### Tier 1: Unconditional Migration (preferred)

If the **new** API exists on the current version too, there is no breaking change — just replace the call site. Wrapping a deprecation in `NextRails.next?` adds dead branches you'll have to clean up for no benefit.
If the **new** API exists on the current version too, there is no breaking change — just replace the call site. Wrapping a deprecation in `NextRails.next?` adds dead branches you'll have to clean up for no benefit. This is the case for almost every Rails-core deprecation at an adjacent-minor boundary because Rails ships the replacement one version before it removes the old form.

❌ **WRONG — `fixture_path` → `fixture_paths` (deprecation only):**

Expand All @@ -53,6 +74,75 @@ Same rule applies to `serialize :preferences, coder: JSON` vs. the older positio

---

### Tier 2: `NextRails.next?` Conditional (for a few call sites)

Reach for a conditional when the gap is genuinely two-sided — the new API doesn't exist on current, or the old one raises on next — and the affected call sites are few (up to ~20). Common triggers:

- **A third-party gem tied to the Rails version has a two-sided API break.** Rails versions often pull in different majors of gems like Rack, minitest, or rspec-rails, or a Rails major replaces a gem-provided feature with a native API using different syntax.
- **You treat deprecations as errors** via `ActiveSupport::Deprecation.behavior = :raise`. A deprecated-but-still-working Rails call now raises on the next side, so the branch lets you use the new API on next and keep the old form on current.
- **The Rails upgrade also forces a Ruby upgrade** with stdlib extractions or syntax differences.

**Example — `ignorable` gem → native `ignored_columns=` (Rails 4.2 → 5.0):**

An app using the [`ignorable`](https://github.com/nthj/ignorable) gem to ignore columns on Rails 4.2 hits a genuine two-sided case when upgrading to 5.0, which introduced a native `ignored_columns=` with different syntax. If the gem is dropped on the 5.0 side (since Rails now has the feature), `ignore_columns :category` raises `NoMethodError` there; `self.ignored_columns += [:category]` raises `NoMethodError` on 4.2 where the setter doesn't exist yet.

```ruby
# app/models/project.rb
class Project < ActiveRecord::Base
if NextRails.next?
self.ignored_columns += [:category]
else
ignore_columns :category
end
end
```

Put the next-version branch on top so cleanup is mechanical: after the upgrade, keep the `if` body and drop the `else`.

---

### Tier 3: Backport / Forwardport Shim (for many call sites)

When a version gap spans an application layer (all controllers, all mailers, all models with the same concern) or affects ~20+ call sites, a single monkey-patch in an initializer (for runtime gaps) or `test_helper.rb` / `spec_helper.rb` (for test-only gaps) lets all call sites stay identical on both sides. The shim makes the lagging version behave like the other one — usually by redefining a method to delegate to an existing equivalent. This is cleaner than scattering dozens of `NextRails.next?` branches and much simpler to remove at cleanup: delete the shim file.

**Two directions:**
- **Backport** — teach the *current* (older) version to behave like the next one, so all call sites are written using the new shape. More common because the new shape is usually the one you want to keep after cleanup.
- **Forwardport** — teach the *next* (newer) version to behave like the current one, so existing call sites keep working unchanged while you migrate them gradually.

Pick whichever direction lets the shared call-site code read best.

**Example — `ActionController::Parameters#values` backport (Rails 7.0 → 7.1):**

Rails 7.1 changed [`ActionController::Parameters#values`](https://github.com/rails/rails/pull/44816) to return an array of `ActionController::Parameters` objects instead of an array of plain hashes. Any controller or service that calls `params.values` and treats the result as hashes will behave differently between the two sides. With many such call sites, a shim is cleaner than scattering conditionals:

```ruby
# config/initializers/ac_parameters_values_backport.rb

# Backport of Rails 7.1's ActionController::Parameters#values behavior so that
# call sites on the Rails 7.0 side also receive an array of
# ActionController::Parameters (not plain hashes).
# Delete this file once the upgrade to Rails 7.1 is complete.
# Reference: https://github.com/rails/rails/pull/44816

if !NextRails.next?
module ActionController
class Parameters
def values
to_enum(:each_value).to_a
end
end
end
end
```

With the shim loaded, every `params.values` call across the app returns the 7.1 shape on both sides — no per-call-site branching. After the upgrade, delete `ac_parameters_values_backport.rb` and nothing else needs to change.

**Caveats for Tier 3:**
- Monkey-patches affect the whole process. Keep the shim narrow (one or two methods), well-commented with a removal marker, and guarded with `if !NextRails.next?` (for a backport) or `if NextRails.next?` (for a forwardport) so it only loads on the side that needs it.
- If the signature difference between versions is severe enough that a faithful shim isn't possible, fall back to Tier 2 — a conditional at each call site is clearer than a lossy shim.

---

## Ruby Version Differences

### Handling stdlib removals (e.g., `net-smtp` extracted in Ruby 3.1)
Expand Down
Loading