Skip to content

feat: rolling_deploy_adapter for seeding previous bundle hashes#3173

Merged
justin808 merged 45 commits into
mainfrom
jg/3122-rolling-deploy-adapter
May 21, 2026
Merged

feat: rolling_deploy_adapter for seeding previous bundle hashes#3173
justin808 merged 45 commits into
mainfrom
jg/3122-rolling-deploy-adapter

Conversation

@justin808
Copy link
Copy Markdown
Member

@justin808 justin808 commented Apr 18, 2026

Summary

Adds a pluggable config.rolling_deploy_adapter protocol that eliminates 410→retry cold-start round-trips for previously-deployed bundle hashes during rolling deploys — complementing the current-hash seeding already handled by PRs #3124 + #3167.

Stacked on #3167 (PR B). Target base is `jg/3122-unify-renderer-cache-staging`; will rebase onto `master` once PRs A + B merge. Refs #3122. Design doc previously shared: see earlier session context.

Why

PR A seeded the current hash. PR B unified copy/symlink staging. But during a rolling deploy:

  • Old Rails instances (hash `abc`) still drain traffic.
  • New Rails instances (hash `def`) serve new traffic.
  • New renderer instances receive requests for both hashes.

Pre-seeding only the current hash leaves `abc` cold on new renderers → every draining-version request hits the 410 retry path. This PR closes that gap.

Protocol

module MyRollingDeployAdapter
  def self.previous_bundle_hashes   # Array<String>
  def self.fetch(bundle_hash)        # Hash(:bundle, :assets) | nil
  def self.upload(bundle_hash, bundle:, assets:)
end

ReactOnRailsPro.configure do |config|
  config.rolling_deploy_adapter = MyRollingDeployAdapter
end

Parallel in shape to `remote_bundle_cache_adapter` — same duck-typed module pattern, same validation at configure time.

The loadable-stats wrinkle

Each bundle hash must carry its own companion `loadable-stats.json` and RSC manifests (built in lockstep with that bundle). Otherwise the renderer emits HTML referencing chunk URLs for the wrong build → client-side hydration breakage. The `fetch` signature returns bundle + assets together to force callers to think about this; see `docs/pro/rolling-deploy-adapters.md` for the full discussion.

Integration points

  1. `PreSeedRendererCache.call` (from PR B) now invokes `RollingDeployCacheStager` after staging the current hash(es). Deduplicates against current hashes.
  2. `AssetsPrecompile.call` auto-invokes `adapter.upload(current_hash, ...)` after precompile in production-like environments, closing the publish side so the next deploy can fetch.
  3. `PREVIOUS_BUNDLE_HASHES` env var (comma-separated) overrides `previous_bundle_hashes` discovery for CI / testing.

Error handling — degrades gracefully

Runtime 410-retry remains the fallback for any seeding failure, so rolling-deploy seeding can never cause a correctness regression:

Scenario Behavior
Adapter not configured No-op.
`previous_bundle_hashes` raises Warn, skip previous-hash seeding.
`fetch` returns nil or raises Warn, skip that hash.
`upload` raises in precompile Warn, don't fail precompile.
Returned hash matches current Deduped.

Doctor (`react_on_rails:doctor`) probes

  • ✅ Protocol conformance (all three required methods).
  • ✅ `previous_bundle_hashes` probe with 10s timeout — reports latency and count.
  • ⚠️ Empty list ("upload side has never run on a prior deploy").
  • ℹ️ Resolved renderer cache dir + bundle-hash subdirs present.
  • ℹ️ `PREVIOUS_BUNDLE_HASHES` env set without an adapter.

Doctor never calls `fetch` or `upload` — those have side effects.

Docs

  • New: `docs/pro/rolling-deploy-adapters.md` — protocol spec, three reference implementations (S3, Control Plane, Filesystem), loadable-stats wrinkle discussion, relationship to `remote_bundle_cache_adapter`.
  • `docs/pro/node-renderer.md` rolling-deploys section updated to point at the new page.
  • Sidebar entry added.

Test plan

  • 7 new specs for `RollingDeployCacheStager` covering every invocation path: adapter unset, hash list discovery, env override, copy mode, symlink mode, fetch nil, fetch raises, previous_bundle_hashes raises, missing :server_bundle in payload, dedup vs current.
  • 5 new doctor specs for `check_rolling_deploy_adapter`.
  • Existing Pro specs updated where `instance_double(Configuration, ...)` needed the new accessor.
  • 36 Pro specs pass locally (27 from PR B + 9 new).
  • 168 doctor specs pass locally (1 pre-existing unrelated failure in `auto_fix_versions`).
  • `bundle exec rubocop` clean on all changed files.
  • CI (will verify after push).

Relationship to other adapters

`remote_bundle_cache_adapter` `rolling_deploy_adapter`
Scope Webpack build outputs (pre-compile caching) Deployed bundle hashes (rolling deploy)
When Build phase Post-precompile + pre-seed phase
Avoids Rebuilding webpack when source hasn't changed 410 retries for draining-version requests
Keyed by Source digest Bundle hash

Complementary — configure both if useful.

🤖 Generated with Claude Code


Note

Medium Risk
Touches Pro deploy-time asset publication and renderer-cache staging paths (assets:precompile and PreSeedRendererCache), which can impact production deploy behavior if misconfigured. Changes are largely additive and guarded with timeouts/warnings, but involve filesystem operations and external adapter calls.

Overview
Introduces a new Pro config.rolling_deploy_adapter protocol (previous_bundle_hashes, fetch, upload) to seed prior bundle hashes into the Node Renderer cache during PreSeedRendererCache, with PREVIOUS_BUNDLE_HASHES available as a discovery override.

Hooks assets:precompile to publish the current server/RSC bundle hashes + companion assets via the adapter (best-effort with per-hash timeouts and warnings), and extends react_on_rails:doctor to validate/probe the adapter (including cache-dir reporting and env-var misuse warnings).

Unifies cache staging helpers and updates staging to auto-include loadable-stats.json when present, adds extensive specs for the stager/doctor/config validation, and documents the protocol with new docs/pro/rolling-deploy-adapters.md plus sidebar/node-renderer doc updates (plus a minor lychee exclude tweak).

Reviewed by Cursor Bugbot for commit ca729db. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Rolling-deploy adapter to pre-seed renderer caches and optionally publish bundles during assets precompile.
    • Automatic inclusion/staging of loadable-stats.json companion assets when present.
  • Configuration

    • New rolling_deploy_adapter option with validation of the adapter’s required interface.
  • Documentation

    • Comprehensive rolling-deploy adapter docs, examples, and expanded Node Renderer rolling-deploy guidance; sidebar entry added.
  • Improved Diagnostics

    • Doctor now reports rolling-deploy adapter status, discovery, and warnings.
  • Tests

    • Extensive spec coverage for rolling-deploy, pre-seed, publishing, and configuration validation.
  • Chores

    • CI link-checker exclusion added for an intermittently failing GitHub URL.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Implements a rolling-deploy adapter protocol and tooling to pre-seed Node renderer caches: adds adapter configuration and validation, RendererCacheHelpers updates, a RollingDeployCacheStager to fetch/stage previous bundle hashes, publishing hooks in assets precompile, doctor diagnostics, docs, and comprehensive specs.

Changes

Rolling Deploy Adapter & Cache Seeding Implementation

Layer / File(s) Summary
Adapter configuration & top-level require
react_on_rails_pro/lib/react_on_rails_pro/configuration.rb, react_on_rails_pro/lib/react_on_rails_pro.rb
Adds rolling_deploy_adapter config with default and validation; enforces required class methods and upload signature shapes; requires the stager module at load time.
RendererCacheHelpers & PreSeed integration
react_on_rails_pro/lib/react_on_rails_pro/renderer_cache_helpers.rb, react_on_rails_pro/lib/react_on_rails_pro/pre_seed_renderer_cache.rb
Adds LOADABLE_STATS_ASSET_NAME, collect_assets, loadable_stats_asset_path, symlink/copy staging helpers; PreSeedRendererCache delegates staging, collects staged hashes, and invokes RollingDeployCacheStager after loop.
Rolling Deploy Cache Stager
react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb
New RollingDeployCacheStager.call(cache_dir:, current_hashes:, mode:) resolves previous hashes (env or adapter), sanitizes/deduplicates, fetches payloads with timeouts, validates bundle+assets and required RSC companion basenames, stages into temp dirs, promotes atomically with backup/restore and concurrency checks, and sweeps stale temp directories.
Bundle Publishing
react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb
Adds publish_current_bundle_if_configured to publish current server and RSC bundles to configured adapter after pre-seed; builds companion-asset list, filters/expands assets, warns on missing required RSC companions, enforces per-upload timeout, and treats upload failures as warnings.
Doctor diagnostics
react_on_rails/lib/react_on_rails/doctor.rb
Adds check_rolling_deploy_adapter to validate adapter protocol, probe previous_bundle_hashes with timeout, handle PREVIOUS_BUNDLE_HASHES env override, and report resolved cache dir bundle counts while excluding staging/previous temp dirs; adds related constants.
Docs & changelog & sidebar & Lychee
docs/pro/rolling-deploy-adapters.md, docs/pro/node-renderer.md, CHANGELOG.md, docs/sidebars.ts, .lychee.toml
New rolling-deploy adapters doc, node-renderer updates, CHANGELOG entries, sidebar entry addition, and a Lychee exclusion for flaky https://github.com/K4sku.
Specs: stager
react_on_rails_pro/spec/dummy/spec/rolling_deploy_cache_stager_spec.rb
Comprehensive spec for RollingDeployCacheStager covering discovery, fetch, staging, promotion, concurrency, cleanup, and RSC-required asset handling.
Specs: assets/config/renderer helpers/doctor
various spec/ files
Adds/updates specs for AssetsPrecompile publication behavior, Configuration rolling_deploy_adapter validation, RendererCacheHelpers.collect_assets, PreSeed/PrepareNodeRenderBundles test doubles, and Doctor checks; many test configs include rolling_deploy_adapter: nil.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hop through bundles, old and new,

seeding hashes, staging true;
symlinks threaded, temps swept bright,
adapters fetch through day and night.
Zero-downtime rolls in sight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature introduced in the PR: adding a rolling_deploy_adapter mechanism for seeding previous bundle hashes into the renderer cache during rolling deploys.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg/3122-rolling-deploy-adapter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9cd1657f83

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
Comment thread docs/pro/rolling-deploy-adapters.md Outdated
Comment thread docs/pro/rolling-deploy-adapters.md Outdated
Comment thread docs/pro/rolling-deploy-adapters.md
Comment thread react_on_rails_pro/lib/react_on_rails_pro/configuration.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 18, 2026

Greptile Summary

This PR introduces RollingDeployCacheStager, a pluggable rolling_deploy_adapter protocol, and auto-upload in AssetsPrecompile to eliminate 410→retry cold-start round-trips for previous bundle hashes during rolling deploys. The overall design — duck-typed adapter module, graceful degradation, doctor probes, and deduplication against current hashes — is solid.

  • P1: In RollingDeployCacheStager.call the early-return guard (return if adapter.nil? && ENV[\"PREVIOUS_BUNDLE_HASHES\"].to_s.empty?) lets the code proceed when PREVIOUS_BUNDLE_HASHES is set but adapter is nil. fetch_payload(nil, hash) then raises NoMethodError: undefined method 'fetch' for nil, rescued with a confusing message. The companion doctor check compounds this by reporting "env override will be used" — misleading operators into thinking the env var is standalone. The fix is to change the guard to return if adapter.nil?.
  • P2: stage_file in :symlink mode creates absolute symlinks (File.symlink(File.expand_path(src), dest)), while PreSeedRendererCache#make_relative_symlink creates relative ones — the inconsistency could break if the cache dir is ever bind-mounted at a different path.

Confidence Score: 4/5

Safe to merge after addressing the nil-adapter + PREVIOUS_BUNDLE_HASHES early-return bug; remaining findings are style/test-coverage.

One P1 defect: the early-return guard allows a nil-adapter path that silently fails with NoMethodError and produces a misleading doctor message. This affects any operator who sets PREVIOUS_BUNDLE_HASHES without an adapter, and it is untested. The P2 absolute-symlink inconsistency is low-risk for typical same-filesystem deploys. All other pieces — configuration validation, graceful degradation, doctor probes, deduplication logic — are well-implemented and tested.

react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb (early-return guard + symlink mode), react_on_rails/lib/react_on_rails/doctor.rb (misleading 'env override will be used' message)

Important Files Changed

Filename Overview
react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb New module for seeding previous bundle hashes; has two issues: early-return guard allows nil-adapter + env-set path that silently fails with NoMethodError, and symlink mode creates absolute rather than relative symlinks unlike PreSeedRendererCache.
react_on_rails_pro/lib/react_on_rails_pro/configuration.rb Adds rolling_deploy_adapter accessor and DEFAULT/validate_rolling_deploy_adapter; validation correctly requires Module with all three methods.
react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb Adds publish_current_bundle_if_configured to upload the just-built bundle after precompile; error handling is correct but missing node_renderer? guard before calling NodeRenderingPool.server_bundle_hash.
react_on_rails/lib/react_on_rails/doctor.rb Adds check_rolling_deploy_adapter probe with protocol conformance check, previous_bundle_hashes latency probe, and cache-dir resolution; doctor message for nil-adapter + env-set case is misleading ("env override will be used") when fetch will actually fail.
react_on_rails_pro/spec/dummy/spec/rolling_deploy_cache_stager_spec.rb 7 specs covering adapter-unset, fetch nil/raises, previous_bundle_hashes raises, missing server_bundle, dedup, copy and symlink modes; missing coverage for PREVIOUS_BUNDLE_HASHES with nil adapter.
react_on_rails_pro/spec/dummy/spec/pre_seed_renderer_cache_spec.rb Correctly updated to include rolling_deploy_adapter: nil in instance_double stubs; all existing paths covered.
react_on_rails/spec/lib/react_on_rails/doctor_spec.rb 5 new doctor specs for rolling_deploy_adapter check covering nil adapter, env override, protocol success/missing, and empty hash list; comprehensive for the happy paths.
react_on_rails_pro/lib/react_on_rails_pro.rb Adds require for rolling_deploy_cache_stager; load order is correct (before pre_seed_renderer_cache which depends on it).
react_on_rails_pro/spec/dummy/spec/prepare_node_renderer_bundles_spec.rb Updated to include rolling_deploy_adapter: nil in instance_double; no functional changes to the spec logic.

Sequence Diagram

sequenceDiagram
    participant AP as AssetsPrecompile.call
    participant PS as PreSeedRendererCache.call
    participant RD as RollingDeployCacheStager.call
    participant DA as rolling_deploy_adapter

    AP->>PS: call(mode: :symlink)
    PS->>PS: stage current bundle hashes
    PS->>RD: call(cache_dir:, current_hashes:, mode:)
    alt adapter nil AND env empty
        RD-->>PS: return (no-op)
    else adapter present OR PREVIOUS_BUNDLE_HASHES set
        RD->>DA: previous_bundle_hashes() [or ENV override]
        DA-->>RD: ["abc", "def", ...]
        RD->>RD: deduplicate against current_hashes
        loop each previous hash
            RD->>DA: fetch(hash)
            DA-->>RD: {server_bundle:, rsc_bundle:, assets:} or nil
            RD->>RD: stage_file(src, dest, mode)
        end
    end
    AP->>AP: publish_current_bundle_if_configured
    AP->>DA: upload(current_hash, server_bundle:, rsc_bundle:, assets:)
Loading

Reviews (1): Last reviewed commit: "feat: add rolling_deploy_adapter for see..." | Re-trigger Greptile

Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 18, 2026

Code Review

This is a well-structured PR with good overall design — the adapter protocol is clear, error handling degrades gracefully at every layer, test coverage is solid, and the docs are thorough. A few issues worth addressing before merge:

Bugs

1. nil adapter + PREVIOUS_BUNDLE_HASHES env var causes a confusing NoMethodError
When PREVIOUS_BUNDLE_HASHES is set but rolling_deploy_adapter is nil, the guard at RollingDeployCacheStager#call passes (nil adapter, non-empty env). resolve_previous_hashes correctly returns the env hashes via the explicit.any? branch, but then fetch_payload(adapter, hash) calls nil.fetch(hash)NoMethodError. The rescue in seed_previous_hash catches it, so it degrades silently, but the warning message will read "NoMethodError" rather than anything actionable. Worse, the doctor check says "env override will be used" — implying standalone operation is supported.

Fix: add an explicit guard after hash resolution when adapter is nil, or document that PREVIOUS_BUNDLE_HASHES always requires a configured adapter for the fetch step. See inline comment at rolling_deploy_cache_stager.rb:65.

Security

2. Shell injection in ControlPlane reference implementation
previous_bundle_hashes uses backtick interpolation with WORKLOAD and GVC, and fetch passes an image string (derived from GVC + hash) to system(string). If any of these contain shell metacharacters, this is command injection. Use the array form of system and Open3.capture2e with argument arrays instead. See inline comment at rolling-deploy-adapters.md:240.

Correctness / Reliability

3. ENV.fetch at class-load time in reference implementations
BUCKET = ENV.fetch("ROLLING_DEPLOY_BUCKET") (S3) and GVC/WORKLOAD (ControlPlane) are evaluated when the file is required. Any environment where these vars aren't set raises KeyError on load. Use lazy def self.bucket = ENV.fetch(...) accessors instead. See inline comment at rolling-deploy-adapters.md:163.

4. No timeout on adapter.fetch during seeding
adapter.fetch(hash) has no timeout guard — only the doctor probe has a 3s timeout. A hung external store will block pre_seed_renderer_cache (and assets:precompile) indefinitely. Consider wrapping with Timeout.timeout. See inline comment at rolling_deploy_cache_stager.rb:98.

5. Race condition in S3 update_manifest!
update_manifest! is a read-modify-write with no concurrency guard. Concurrent deploys silently lose entries (last writer wins). At minimum document this; optionally use a conditional PUT with if_match: etag. See inline comment at rolling-deploy-adapters.md:218.

Style / Idioms

6. methods.include? vs respond_to?
Both validate_rolling_deploy_adapter and report_adapter_protocol use adapter.methods.include?(m). respond_to? is idiomatic for duck-typing checks and handles method_missing-based delegation. Note the existing validate_remote_bundle_cache_adapter uses the same pattern — if there's a reason, it's worth a comment. See inline comment at configuration.rb:266.

7. module_function + private_class_method vs class << self
RollingDeployCacheStager uses module_function then private_class_method. This also installs private instance method copies on any includer — almost certainly unintended. class << self with private is cleaner and matches PreSeedRendererCache. See inline comment at rolling_deploy_cache_stager.rb:63.

Minor

  • publish_current_bundle_if_configured uses puts for success and warn for errors — inconsistent; consider warn or Rails.logger throughout so success messages aren't swallowed in background processes.
  • The check_rolling_deploy_adapter spec doesn't cover the 3s timeout path; a test with a stubbed slow previous_bundle_hashes would close that gap.

🤖 Generated with Claude Code

justin808 added a commit that referenced this pull request Apr 18, 2026
MUST-FIX (2):

- **Nil adapter + PREVIOUS_BUNDLE_HASHES bug** (greptile P1, claude):
  when the env override was set without config.rolling_deploy_adapter,
  the stager proceeded past the early-return guard and crashed with
  NoMethodError at adapter.fetch. Now warns and returns instead.
  Doctor promotes the corresponding check from :info to :warning and
  explains that both env and adapter are required.

- **RSC previous-bundle staging path** (codex P2): the renderer routes
  RSC requests by rsc_bundle_hash, which differs from server_bundle_hash
  and is a separate <cache>/<hash>/<hash>.js entry. The original
  protocol merged both bundles under a single hash's directory using
  basenames, so the RSC bundle never landed where the renderer looks.
  Fix: protocol refactor — each hash is now one bundle's cache entry.

    fetch(hash) → { bundle: path, assets: [paths] }
    upload(hash, bundle:, assets:)
    previous_bundle_hashes → flat list including server AND rsc hashes

  AssetsPrecompile.publish_current_bundle_if_configured now calls
  upload twice when RSC is enabled (once per hash). Stager stages each
  hash at <cache>/<hash>/<hash>.js with its companion assets.

SHOULD-FIX (4):

- **Timeout on adapter.fetch** (claude): wrap with Timeout.timeout
  (default 30s) so a hung external store can't block pre-seeding or
  assets:precompile indefinitely.

- **Relative symlinks** (greptile P2): stage_file in :symlink mode now
  uses the same Pathname#realpath + relative_path_from pattern as
  PreSeedRendererCache.make_relative_symlink, matching current-hash
  staging and surviving cache-dir moves.

- **Remove stray module_function** (claude): was paired with
  private_class_method (an unusual combination that leaks private
  instance methods to any include-r). Switched to `def self.` for all
  methods, private_class_method for helpers.

- **respond_to? instead of methods.include?** (claude): more idiomatic
  duck-typing in both Configuration.validate_rolling_deploy_adapter
  and Doctor.report_adapter_protocol.

DOC POLISH (3):

- Control Plane reference impl: switched from backtick/system shell
  interpolation to Open3.capture2e with array-form args to avoid
  shell injection via env-var contents. Returns both server AND RSC
  hashes via previous_bundle_hashes.

- Both S3 and Control Plane examples: lazy env-var accessor methods
  instead of constants evaluated at require time, so KeyError can't
  fire at class-load in environments that don't configure the adapter.

- S3 reference impl: explicit note about the read-modify-write race
  in update_manifest!; documents serializing deploys or using
  If-Match/ETag as strict-safety alternatives.

- Updated protocol docstrings + all reference impls (S3, Control Plane,
  Filesystem) to the new fetch/upload signatures.

Tests: 13 rolling-deploy specs now (was 7) — adds nil-adapter+env
warning, fetch timeout, relative-symlink assertion. Doctor spec
updated for the warn-vs-info change. 38 Pro specs pass, 167/168
doctor specs pass (1 pre-existing unrelated failure).

Refs: #3122, #3167 (PR B), #3173 (this PR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@justin808
Copy link
Copy Markdown
Member Author

Addressed review feedback in 2e9b3f8

MUST-FIX (2):

  1. Nil adapter + `PREVIOUS_BUNDLE_HASHES` crash (greptile P1, claude) — when the env override was set without a configured adapter, the guard let execution proceed and `adapter.fetch(hash)` crashed on `nil`. Now warns and returns early. Doctor promotes the corresponding message from `:info` to `:warning` and explains that both env and adapter are required.

  2. RSC previous-bundle staging path (codex P2) — the renderer routes RSC requests by `rsc_bundle_hash`, which is a separate `//.js` entry from the server bundle. The original protocol merged both bundles under one hash's directory using basenames, so the RSC bundle never landed where the renderer looks. Protocol refactor:

    • `fetch(hash)` → `{ bundle: path, assets: [paths] }` (opaque per-hash).
    • `upload(hash, bundle:, assets:)`.
    • `previous_bundle_hashes` returns a flat list including both server and RSC hashes.

    `AssetsPrecompile.publish_current_bundle_if_configured` now calls `upload` twice when RSC is enabled (once per hash). Stager stages each hash at `//.js`.

SHOULD-FIX (4):

  1. `Timeout.timeout` on `adapter.fetch` (claude) — 30s default. Prevents a hung S3 / network call from blocking pre-seeding or `assets:precompile`.
  2. Relative symlinks (greptile P2) — `stage_file` in `:symlink` mode now uses the same `Pathname#realpath` + `relative_path_from` pattern as `PreSeedRendererCache.make_relative_symlink`.
  3. Remove stray `module_function` (claude) — was leaking private instance methods to any `include`r. All methods now `def self.`; private helpers marked `private_class_method`.
  4. `respond_to?` instead of `methods.include?` (claude) — applied to both `Configuration.validate_rolling_deploy_adapter` and `Doctor.report_adapter_protocol`.

DOC POLISH (3):

  1. Control Plane example — migrated from backtick/`system` shell interpolation to `Open3.capture2e` with array-form args. `previous_bundle_hashes` now returns both `REACT_ON_RAILS_BUNDLE_HASH` and `REACT_ON_RAILS_RSC_BUNDLE_HASH` so RSC deploys seed correctly.
  2. Lazy env-var accessors — S3 and Control Plane examples now read env vars inside methods rather than at class-load time, so `KeyError` can't fire on `require` in environments that don't configure the adapter.
  3. S3 race condition note — explicit callout that `update_manifest!` is read-modify-write; documents serialized deploys or `If-Match`/ETag as strict-safety alternatives.

Tests: 13 rolling-deploy specs (was 7) — adds nil-adapter+env warning, fetch timeout, relative-symlink assertion. Doctor spec updated for the warn-vs-info change. 38 Pro specs pass, 167/168 doctor specs pass (1 pre-existing unrelated failure in `auto_fix_versions`, unchanged).

I (Claude Code) addressed the feedback on behalf of @justin808.

Comment thread react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb
Comment thread docs/pro/rolling-deploy-adapters.md Outdated
Comment thread docs/pro/rolling-deploy-adapters.md Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb
Comment thread docs/pro/rolling-deploy-adapters.md Outdated
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 18, 2026

Code Review — PR #3173: rolling_deploy_adapter protocol

Overall this is a well-designed addition. The duck-typed protocol mirrors remote_bundle_cache_adapter, the error-handling table is thorough, the doctor probes are appropriately scoped (no fetch/upload side-effects), and the 7 stager specs cover all the edge cases cleanly. A few issues worth addressing before merge:


Bugs / correctness

1. publish_current_bundle_if_configured is missing a node_renderer? guard (inline comment on assets_precompile.rb:83)

NodeRenderingPool.server_bundle_hash is referenced unconditionally. If a user configures rolling_deploy_adapter while still using ExecJS this will raise at precompile time. The sibling call to PreSeedRendererCache.call already has a node_renderer? guard — publish_current_bundle_if_configured should too.

2. Path traversal via PREVIOUS_BUNDLE_HASHES (inline comment on rolling_deploy_cache_stager.rb:62)

Env-var values flow directly into File.join(cache_dir, hash) without any sanitisation. A value like ../../../tmp/evil escapes the cache directory. Adapter-returned hashes come from your own code and are presumably safe; the env-var path is not. A simple allowlist regex (/\A[A-Za-z0-9_\-]+\z/) covers all realistic hash formats.


Missing test coverage

publish_current_bundle_if_configured (new in assets_precompile.rb) has zero specs. This is the hot path during every production assets:precompile — it calls the adapter, calls NodeRenderingPool class methods, and has branching for RSC. At minimum, add specs for: adapter nil → no-op; development env → no-op; upload succeeds; upload raises → warns but doesn't propagate; RSC enabled → uploads twice.


Reference implementation issues (inline comments on rolling-deploy-adapters.md)

3. ControlPlane adapter: Dir[File.join(tmp, "*.json")] is too broad (line 236)
An image pull may deposit lock files or other JSON alongside the manifests. The S3 adapter explicitly names loadable-stats.json, react-client-manifest.json, react-server-client-manifest.json — the Control Plane adapter should do the same.

4. S3 adapter: @s3 ||= is not thread-safe (line 162)
Under concurrent callers two threads can each instantiate an Aws::S3::Client. Wrapping with a Mutex (or simply not memoizing, given the call frequency) fixes this.

5. update_manifest! stores RETENTION + 2 but previous_bundle_hashes returns RETENTION (line 187)
This is likely intentional (soft buffer) but undocumented, making the code confusing. A one-line comment explaining the intent would help.


Minor observations (not blocking)

  • FilesystemRollingDeployAdapter#fetch returns dir.join("bundle.js").to_s without checking File.exist?. The stager's fetch_payload handles this, but it silently violates the contract the protocol doc sets out (returning a non-nil payload that fails the existence check). Consider checking existence before returning or returning nil.
  • The Dir[dir.join("*.json")] in FilesystemRollingDeployAdapter#fetch passes a Pathname to Dir[]. Ruby calls to_s on it so it works, but dir.join("*.json").to_s is more explicit about intent.
  • validate_rolling_deploy_adapter in configuration.rb is not directly unit-tested. The doctor specs exercise a similar check but not the configuration-time validation path.

🤖 Generated with Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e9b3f8e58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

justin808 added a commit that referenced this pull request Apr 18, 2026
Post-revision review surfaced 7 new actionable items. All fixed.

Code:

1. PREVIOUS_BUNDLE_HASHES path-traversal (claude): env values are used
   as directory names under the renderer cache; a malicious or mistyped
   value like "../../../etc" would escape the cache dir. Now reject
   anything that doesn't match /\A[A-Za-z0-9_\-.]+\z/ with a warning.

2. node_renderer? guard in publish_current_bundle_if_configured
   (claude): returns early when ExecJS is configured rather than
   crashing on NodeRenderingPool.server_bundle_hash. Mirrors the
   guard used by PreSeedRendererCache.

5. Top-level rescue in publish_current_bundle_if_configured (cursor
   Medium): collect_assets / server_bundle_hash / rsc_bundle_js_file_path
   can all raise from the setup code that ran before the per-upload
   rescue. Extracted publish_bundles helper and wrapped the whole
   publication path with StandardError rescue so a failure degrades
   next-deploy seeding but doesn't fail this deploy's assets:precompile.

7. Partial-staging cleanup on asset failure (codex P1): previously if
   stage_file succeeded on the bundle but failed on an asset, the
   cache held a bundle file without its companion manifests. The
   renderer would then find the bundle and serve without those
   manifests, causing hydration breakage instead of the clean 410
   retry fallback. Now rm_rf the hash directory on any failure inside
   seed_previous_hash — restores the 410-retry safety net.

Docs:

3. Control Plane fetch example: replaced Dir[tmp/*.json] with an
   explicit allowlist so stray JSON metadata in the image layer
   isn't silently picked up.

4. S3 client mutex: @s3 ||= is not thread-safe under concurrent
   callers (parallel workers running precompile hooks). Example now
   uses a Mutex-synchronized memoizer with a doc comment.

6. S3 manifest RETENTION alignment: write and read both trim to
   RETENTION now, so the persisted manifest matches what discovery
   returns. Added a comment explaining the intent.

Tests: 40 rolling-deploy + pre-seed + shim specs (was 38, +2 for
path-traversal reject and partial-staging cleanup). Rubocop clean.

Refs: #3122, #3173

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@justin808
Copy link
Copy Markdown
Member Author

Addressed post-revision review feedback in fb3de1b

Seven new actionable items since `2e9b3f8e5` — all fixed. None skipped.

Security / correctness:

  1. Path traversal via `PREVIOUS_BUNDLE_HASHES` (claude) — hash values are used as directory names. Now reject anything not matching `/\A[A-Za-z0-9_\-.]+\z/` with a warning. Spec asserts `../../../etc` is rejected while safe hashes continue through.
  2. `node_renderer?` guard in `publish_current_bundle_if_configured` (claude) — skip publication under ExecJS renderer. Without this, `NodeRenderingPool.server_bundle_hash` would crash precompile.
  3. Top-level rescue in publish method (cursor Medium) — `collect_assets` / `server_bundle_hash` / `rsc_bundle_js_file_path` can raise from the setup code. Wrapped the whole publication path with `rescue StandardError` so failures degrade next-deploy seeding but don't fail this deploy's `assets:precompile`.
  4. Partial-staging cleanup on asset failure (codex P1) — previously a failed asset copy after a successful bundle copy would leave the cache with a bundle without its companion manifests. Renderer would serve it, producing hydration failures instead of the clean 410-retry fallback. Now `rm_rf` the hash directory on any failure inside `seed_previous_hash`. Spec asserts the bundle dir is gone after a mid-stage failure.

Docs:

  1. Control Plane fetch allowlist (claude) — replaced `Dir[tmp/*.json]` with explicit `loadable-stats.json` + RSC manifest allowlist. Stray image-layer JSON won't be picked up.
  2. S3 client mutex (claude) — `@s3 ||=` is not thread-safe under concurrent callers. Example now uses `Mutex.synchronize` memoization with a doc comment.
  3. S3 RETENTION alignment (claude) — both read and write now trim to `RETENTION`; dropped the `+2` asymmetry. Added comment explaining the intent for users who want a soft buffer.

Tests: 40 specs pass (was 38; +2 for path-traversal reject + partial-staging cleanup). Rubocop clean. Pre-commit hooks pass.

I (Claude Code) addressed the feedback on behalf of @justin808.

Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
Comment thread docs/pro/rolling-deploy-adapters.md
Comment thread react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb
Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb Outdated
Comment thread .rubocop.yml
Comment thread react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 21, 2026

Code Review: PR 3173 rolling_deploy_adapter - see inline comments for specifics.

Overview: Solid PR completing the rolling-deploy trilogy. Graceful-degradation design is well-executed — every failure path (timeout, nil, raise) falls back to 410-retry, so the adapter can never break a deploy.

Strengths: Airtight graceful degradation; defense-in-depth path traversal protection; atomic two-mv staging with concurrent-writer detection; upload signature validation at configure-time; thorough reference implementations.

Six issues flagged via inline comments: (1) missing blank line after private_class_method block in assets_precompile.rb:218-219; (2) SAFE_HASH_PATTERN allows leading-dot hashes that create hidden cache dirs; (3) raw regex source in operator warning is not human-readable; (4) display_label.downcase produces 'rsc' lowercase mid-sentence in RSC warning; (5) global Metrics/ModuleLength bump should be a file-level disable; (6) splat-style upload(*args) accepted but undocumented for adapter authors.

Minor: rolling_deploy_discovery_timeout_seconds fallback could reference the exact Pro spec path; stub_synchronizer helper refactor in doctor_spec.rb is a clean improvement.

assets_precompile.rb:
- Defer pool.server_bundle_hash / pool.rsc_bundle_hash evaluation behind a
  block in publish_bundle_if_present so a missing bundle file no longer
  raises from bundle_hash (File.mtime / Digest::MD5.file) and bypasses the
  per-bundle "does not exist" warning. Added regression spec.
- Use the caller's original bundle_label (not display_label.downcase) in
  the missing-bundle warning so "RSC bundle ... for RSC bundle" reads
  consistently instead of mixing cases.
- Added blank line between private_class_method list and the next def.

rolling_deploy_cache_stager.rb:
- SAFE_HASH_PATTERN now rejects any leading-dot hash (`.hidden`, `.git`)
  in addition to `.` and `..`. Path traversal was already prevented by
  start_with?, but a leading-dot hash would silently create a hidden
  cache subdirectory. Added regression spec.
- Replaced the raw SAFE_HASH_PATTERN.source dump in the sanitize_hashes
  warning with a plain-English description that operators can act on.
- Backup the bundle_dir entry when it's a dangling symlink, not just when
  File.exist? reports true, so restore_previous_bundle_directory has
  something to restore if promotion fails after the first move.
- Documented Timeout.timeout's quasi-random interrupt point and the
  platform divergence (Linux nests staging-dir / macOS+BSD raise
  ENOTEMPTY) inline so future maintainers don't think the nested-dir
  check is the only detection path.
- Documented STALE_TEMP_DIR_TTL_SECONDS so operators on slow builds can
  tune it.

configuration.rb:
- validate_rolling_deploy_upload_signature now points adapter authors at
  the splat/options-hash variants too, so a `def upload(*args)` author
  knows the keyword pair lands inside args.

docs/pro/rolling-deploy-adapters.md:
- Quantified S3 cost note: 2x GetObject + 2x PutObject per deploy when
  RSC is enabled (vs. the prior generic "two cycles" phrasing).
- Documented splat/options-hash upload signature variants alongside the
  explicit form.

.rubocop.yml:
- Reverted the global Metrics/ModuleLength bump back to 180. The
  rolling_deploy_cache_stager.rb file-level `# rubocop:disable
  Metrics/ModuleLength` already covers the one over-limit case, so the
  global bump was silently relaxing the rule for every other module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

def self.publish_bundle_if_present(adapter, bundle, assets, bundle_label)
if File.file?(bundle)
publish_bundle(adapter, yield, bundle, assets, bundle_label)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The implicit yield here means callers must pass a block, but the method signature doesn't declare &block, so this contract isn't visible from the definition — only from the call sites at lines 121 and 126.

When the file doesn't exist the block is silently not called, which is intentional for deferred hash computation. But a future caller who reads only the signature could pass the method without a block, gets LocalJumpError at runtime (only in the file-present branch), and has to trace back to understand why.

Consider using an explicit &block parameter:

Suggested change
publish_bundle(adapter, yield, bundle, assets, bundle_label)
publish_bundle(adapter, block.call, bundle, assets, bundle_label)

and declare the method as def self.publish_bundle_if_present(adapter, bundle, assets, bundle_label, &block). The &block makes the requirement visible in signatures, method docs, and IDEs.


required_positionals = params.count { |type, _name| type == :req }
optional_positionals = params.count { |type, _name| type == :opt }
required_positionals == 1 && optional_positionals.positive?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This branch accepts upload(hash, options = {}) — but in Ruby 3.0+ keyword arguments are strictly separated from positional hashes. A call like adapter.upload(hash, bundle: bundle, assets: assets) will not populate options; Ruby 3 raises ArgumentError: unknown keywords: bundle, assets instead.

The comment above correctly documents the Ruby 3 rule for *args, but the positional optional case (opt param kind) falls through to this return true without the same guard. An adapter author who writes def self.upload(hash, opts = {}) will pass validation here, deploy to a Ruby 3 app, and get a runtime ArgumentError on the first assets:precompile — a confusing failure mode given that configure accepted the adapter cleanly.

Suggestion: either reject this shape (add || optional_positionals.positive? to the uses_explicit_upload_keywords? guard), or add an explicit config-time warning that upload(hash, opts = {}) only works on Ruby 2.7 and below.

@justin808
Copy link
Copy Markdown
Member Author

Review round 10 — address-review summary

Posted by me (Claude Code) after running /address-review on PR #3173. Commit: ca729db.

Addressed in code (12 items)

# File:line Change Reviewer
1 assets_precompile.rb:117 (P2/must-fix) Defer pool.server_bundle_hash / pool.rsc_bundle_hash evaluation behind a block so missing-bundle warnings still fire when bundle_hash (File.mtime / Digest::MD5.file) would otherwise raise. + regression spec. @chatgpt-codex-connector[bot]
2 .rubocop.yml:80 (must-fix) Reverted Metrics/ModuleLength global to 180; the file-level disable on rolling_deploy_cache_stager.rb:32 covers the one over-limit module. @claude[bot] (×2)
3 rolling_deploy_cache_stager.rb:34-50 Documented Timeout.timeout interrupt-point caveats + expanded STALE_TEMP_DIR_TTL_SECONDS rationale. @claude[bot] (×2)
4 rolling_deploy_cache_stager.rb:89 Tightened SAFE_HASH_PATTERN to reject any leading-dot hash (.hidden, .git, …), not just ./... + regression spec. @claude[bot]
5 rolling_deploy_cache_stager.rb:380 Replaced raw SAFE_HASH_PATTERN.source in warning with plain-English description. @claude[bot]
6 rolling_deploy_cache_stager.rb:431-450 Backup dangling symlinks (not just files/dirs) in replace_bundle_directory; documented Linux vs macOS/BSD TOCTOU divergence inline. @claude[bot] (×2)
7 assets_precompile.rb:195 Use original bundle_label (caller's casing) instead of display_label.downcase so "RSC bundle … for RSC bundle" reads consistently. @claude[bot]
8 assets_precompile.rb:225 Added blank line between private_class_method list and next def. @claude[bot] (×2)
9 configuration.rb:285 + docs Expanded error message and docs to describe splat/options-hash upload signatures (def upload(*args), def upload(hash, **opts)). @claude[bot]
10 docs/pro/rolling-deploy-adapters.md:139 Quantified S3 manifest cost: 2× GetObject + 2× PutObject per deploy when RSC is enabled. @claude[bot]

Already addressed in prior commits — replied + resolved (19 items)

These threads carry feedback that was silently fixed by subsequent rounds (≤ ba13457) but never closed out. Verified against current code, replied with the resolution, and resolved:

  • doctor.rb:766 FIX=true guard already in place
  • Sequential per-hash seeding documented as intentional
  • TEMPORARY_DIRECTORY_PATTERN PID-1 handling already correct
  • Array#compact .dup / Array().dup no-ops removed
  • Duplicate RENDERER_BUNDLE_PATH comment block removed (3 reports)
  • "whitespace-only" internal note removed from user-facing error
  • All bare raise "…" converted to raise ReactOnRailsPro::Error, … (2 reports)
  • sanitize_hashes:378 now rejects TEMPORARY_DIRECTORY_PATTERN matches (2 reports)
  • replace_existing_symlink dead code removed
  • bundle_directory uses File::SEPARATOR
  • required_rsc_asset_paths now receives manifests (fixed in ba13457)
  • Older Ruby-3-*args thread superseded by new docs/error-message fix above
  • Older capitalize/downcase thread superseded by the same-area fix above
  • Unconditional sweep documented as intentional with TTL safeguard

Verification

  • bundle exec rubocop — clean on all changed files; --only Metrics/ModuleLength clean across the repo.
  • bundle exec rspec spec/react_on_rails_pro/assets_precompile_spec.rb — 40/40 pass (includes new regression).
  • bundle exec rspec spec/dummy/spec/rolling_deploy_cache_stager_spec.rb — 48/48 pass (includes new leading-dot rejection spec).
  • bundle exec rspec spec/react_on_rails_pro/ — 394/394 pass.
  • bundle exec rspec spec/lib/react_on_rails/doctor_spec.rb — 228/228 pass.

🤖 Generated with Claude Code


if adapter.nil?
env_override = ENV.fetch("PREVIOUS_BUNDLE_HASHES", nil)
if env_override && !env_override.empty?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor inconsistency with the stager: handle_missing_adapter in rolling_deploy_cache_stager.rb does ENV["PREVIOUS_BUNDLE_HASHES"].to_s.strip and then checks emptiness, so a value like " " (spaces only) is treated as unset. Here the check is !env_override.empty?, which would consider " " non-empty and fire the warning — a false positive.

The mismatch is cosmetic in practice (an all-whitespace env var is likely accidental regardless), but aligning the two checks avoids confusing discrepancy:

Suggested change
if env_override && !env_override.empty?
if env_override && !env_override.strip.empty?

return unless report_adapter_protocol(adapter)

env_override = ENV.fetch("PREVIOUS_BUNDLE_HASHES", nil)
if env_override && !env_override.empty?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same whitespace inconsistency as the check at line 2889 — aligning with the stager's .strip avoids a false-positive "skipping probe" info message for an all-whitespace value:

Suggested change
if env_override && !env_override.empty?
if env_override && !env_override.strip.empty?

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create an environment for this repo.


PREFIX = "bundles"
MANIFEST_KEY = "#{PREFIX}/_manifest.json".freeze
RETENTION = 6 # keep last ~3 deploys' worth (2 hashes per deploy when RSC is enabled)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment "~3 deploys' worth (2 hashes per deploy when RSC is enabled)" is accurate for RSC apps, but the same RETENTION = 6 value is used unchanged for non-RSC apps — where each deploy produces only 1 hash. In that case RETENTION = 6 keeps the last 6 deploys, not 3.

Since most teams only need to cover draining traffic from the previous 1–2 deploys, an operator who copy-pastes this for a non-RSC app will silently retain far more history than intended.

Consider either:

  • Documenting both cases explicitly: RETENTION = 6 # ~3 deploys with RSC (2 hashes/deploy), or ~6 deploys without RSC (1 hash/deploy), or
  • Making retention configurable with a smaller default for non-RSC (e.g. RETENTION = enable_rsc ? 6 : 2).

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 21, 2026

Code review posted with inline comments on 4 specific issues. Summary: (1) Ruby 3 incompatibility in upload signature validation - accepts_upload_options_hash allows upload(hash, opts={}) which fails at runtime on Ruby 3+. (2) Implicit yield without &block declaration in publish_bundle_if_present. (3) PREVIOUS_BUNDLE_HASHES whitespace check inconsistency between doctor.rb and stager. (4) RETENTION=6 doc comment misleading for non-RSC apps.

@justin808 justin808 merged commit 50bf53b into main May 21, 2026
117 checks passed
@justin808 justin808 deleted the jg/3122-rolling-deploy-adapter branch May 21, 2026 08:30
justin808 added a commit that referenced this pull request May 21, 2026
* origin/main:
  docs: explain responseEnd/action_total range overlap for Gumroad RSC benchmark (#3340)
  chore(ci): upgrade k6 benchmarks to v2.0.0 (#3319) (#3338)
  docs: add resolve.alias alternative for legacy Webpacker shim (#3252) (#3330)
  fix: scan .mts and .cts in Pro migration base-package check (#3250) (#3334)
  Bump version to 16.7.0.rc.0
  Update CHANGELOG.md for 16.7.0.rc.0 (#3336)
  feat: rolling_deploy_adapter for seeding previous bundle hashes (#3173)
  docs: capture Gumroad benchmark JS transfer-size artifacts (#3259) (#3333)
  [codex] Document node renderer health probes (#3241)
  ci(lychee): exclude flyhomes.com from markdown link check (#3327)
  feat(doctor): expand renderer-cache scan to CI/CD manifests (#3329)
justin808 added a commit that referenced this pull request May 22, 2026
…y-patch-DrhDk-v1

* origin/main: (26 commits)
  docs: consolidate node renderer probe settings (#3257) (#3348)
  chore(ci): remove obsolete setup-node-with-retry action (#3352)
  chore: remove completed planning/analysis docs (#3361)
  test: stub stable VERSION in --pro install spec to unblock CI (#3363)
  docs(specs): add RC testing plan design spec (#3350)
  docs: point users at bin/shakapacker-config --doctor for bundler-config debugging (#3359)
  docs: polish RSC node renderer test recipe (#3273) (#3339)
  test: replace call_count stub pattern with and_return sequence (#2157) (#3353)
  docs: call out Inertia's incompatibility with client-side routers (#3356)
  docs: replace personal email with contact URL in Pro README (#3347)
  docs: plan performance test expansion (#3238)
  docs: explain responseEnd/action_total range overlap for Gumroad RSC benchmark (#3340)
  chore(ci): upgrade k6 benchmarks to v2.0.0 (#3319) (#3338)
  docs: add resolve.alias alternative for legacy Webpacker shim (#3252) (#3330)
  fix: scan .mts and .cts in Pro migration base-package check (#3250) (#3334)
  Bump version to 16.7.0.rc.0
  Update CHANGELOG.md for 16.7.0.rc.0 (#3336)
  feat: rolling_deploy_adapter for seeding previous bundle hashes (#3173)
  docs: capture Gumroad benchmark JS transfer-size artifacts (#3259) (#3333)
  [codex] Document node renderer health probes (#3241)
  ...

# Conflicts:
#	PROJECTS.md
justin808 added a commit that referenced this pull request May 26, 2026
Part 1 of #3240 (zero-infra rolling-deploy default). Adds the
client-side adapter, server-side controller, and config plumbing.
Follow-up PRs add routes auto-mount, specs, and docs.

What lands:

- ReactOnRailsPro::RollingDeployAdapters::Http — built-in adapter that
  fetches previous bundle hashes over authenticated HTTP. Implements
  the rolling_deploy_adapter protocol from #3173: previous_bundle_hashes
  hits /manifest, fetch(hash) downloads + extracts a gzipped tarball
  into tmp/rolling-deploy/<hash>/, upload is a no-op (the running Rails
  server IS the artifact store).

- ReactOnRailsPro::RollingDeploy::BundlesController — mountable
  controller serving GET /manifest (current deployment's bundle hashes)
  and GET /bundles/:hash (gzipped tarball of bundle + companion assets).
  Constant-time bearer-token auth. Hash allowlist prevents path
  traversal by construction. Cache-Control: no-store on every response.

- ReactOnRailsPro::RollingDeploy::Tarball — stdlib-only tar.gz
  compose/extract with size cap, path-safety guard (basename only,
  no leading dot), and regular-files-only enforcement to defeat
  zip-bomb / symlink-traversal payloads.

- Configuration:
    config.rolling_deploy_token         (required when adapter is Http)
    config.rolling_deploy_previous_url  (where to fetch from)
    config.rolling_deploy_mount_path    (default: /react_on_rails_pro/rolling_deploy)
  Token validated at configure time: required + min 32 chars when the
  Http adapter is selected. Custom adapters re-using the same knobs
  are not forced through this validation.

Still TODO in follow-up PRs on this branch:

- Routes auto-mount via engine initializer (currently controller is
  defined but not wired into the route table).
- Adapter + controller specs.
- docs/pro/rolling-deploy-http-adapter.md setup walkthrough.
- CHANGELOG entry once the feature lands end-to-end.
- Rate limiting, RENDERER_PASSWORD fallback, doctor probes (deferred
  to PR 2 per agreed split).

Refs #3240
Builds on #3173 (rolling_deploy_adapter protocol)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
justin808 added a commit that referenced this pull request May 26, 2026
Part 1 of #3240 (zero-infra rolling-deploy default). Adds the
client-side adapter, server-side controller, and config plumbing.
Follow-up PRs add routes auto-mount, specs, and docs.

What lands:

- ReactOnRailsPro::RollingDeployAdapters::Http — built-in adapter that
  fetches previous bundle hashes over authenticated HTTP. Implements
  the rolling_deploy_adapter protocol from #3173: previous_bundle_hashes
  hits /manifest, fetch(hash) downloads + extracts a gzipped tarball
  into tmp/rolling-deploy/<hash>/, upload is a no-op (the running Rails
  server IS the artifact store).

- ReactOnRailsPro::RollingDeploy::BundlesController — mountable
  controller serving GET /manifest (current deployment's bundle hashes)
  and GET /bundles/:hash (gzipped tarball of bundle + companion assets).
  Constant-time bearer-token auth. Hash allowlist prevents path
  traversal by construction. Cache-Control: no-store on every response.

- ReactOnRailsPro::RollingDeploy::Tarball — stdlib-only tar.gz
  compose/extract with size cap, path-safety guard (basename only,
  no leading dot), and regular-files-only enforcement to defeat
  zip-bomb / symlink-traversal payloads.

- Configuration:
    config.rolling_deploy_token         (required when adapter is Http)
    config.rolling_deploy_previous_url  (where to fetch from)
    config.rolling_deploy_mount_path    (default: /react_on_rails_pro/rolling_deploy)
  Token validated at configure time: required + min 32 chars when the
  Http adapter is selected. Custom adapters re-using the same knobs
  are not forced through this validation.

Still TODO in follow-up PRs on this branch:

- Routes auto-mount via engine initializer (currently controller is
  defined but not wired into the route table).
- Adapter + controller specs.
- docs/pro/rolling-deploy-http-adapter.md setup walkthrough.
- CHANGELOG entry once the feature lands end-to-end.
- Rate limiting, RENDERER_PASSWORD fallback, doctor probes (deferred
  to PR 2 per agreed split).

Refs #3240
Builds on #3173 (rolling_deploy_adapter protocol)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
justin808 added a commit that referenced this pull request May 27, 2026
…3379)

### Summary

Part 1 of #3240 — scaffolds the built-in HTTP rolling-deploy adapter
(zero-infra default for the `rolling_deploy_adapter` protocol from
#3173). Lands the client-side
`ReactOnRailsPro::RollingDeployAdapters::Http` adapter, the server-side
`ReactOnRailsPro::RollingDeploy::BundlesController` (`GET /manifest` +
`GET /bundles/:hash`), a stdlib-only `Tarball` compose/extract helper,
and three new config options (`rolling_deploy_token`,
`rolling_deploy_previous_url`, `rolling_deploy_mount_path`) with
required-and-≥32-byte token validation at configure time. Security
model: constant-time bearer-token auth (length leak documented inline,
≥32 bytes enforced by the validator), hash allowlist (path-traversal
proof by construction), `Cache-Control: no-store`, tarball entries must
be flat basenames + regular files, uncompressed-size cap defeats
zip-bomb payloads. Refs #3240, builds on #3173.

### Latest update — review round 4 (e6a3a04)

Worked through the 26 open review threads. Highlights:

- **Correctness**: `gz.finish` now wrapped in `rescue` (FD hygiene on
the exception path); `ENTRY_NAME_PATTERN` dropped a redundant `(?!\.)`
lookahead and its inaccurate "stricter" comment was rewritten; config
validator switched from `.length` to `.bytesize` to match the auth-path
constant-time check.
- **Operator UX**: warn-log when `node_renderer?` is false; loopback
hosts now exempt from the plain-HTTP cleartext-token warning so
local-dev rehearsals stay quiet.
- **Tests**: 11 new examples for `#authenticate_rolling_deploy_request`
— the previously-untested security-critical path — plus a loopback-host
lock-in in `http_spec.rb`. All 106 relevant local specs pass; rubocop
and RBS clean.
- **CHANGELOG**: `Added` entry for the new adapter, and a `Changed`
entry documenting that the shared `SAFE_HASH_PATTERN` now rejects bundle
hashes starting with `-` (a tightening over the previous local pattern
in `RollingDeployCacheStager`; no-op for webpack content hashes).
- **Discuss item resolution**: `bytesize` pre-`secure_compare` gate
kept, with an expanded comment explaining why the leak is bounded to
token length. The SHA256-digest alternative remains an option if a
stronger constant-time path is preferred in a follow-up.

5 review comments were rationale-replied + resolved as already-addressed
/ not-applicable. All 26 open threads are now resolved.

### Pull Request checklist

- [x] Add/update test to cover these changes
- [x] Update documentation
- [x] Update CHANGELOG file

### Other Information

Per the agreed PR split, **the hard HTTPS-only gate, the
`RENDERER_PASSWORD` fallback, streaming download (currently
`response.body` buffers up to 200 MB), and doctor probes remain deferred
to follow-ups** — orthogonal to the "did we solve the zero-infra
problem" question this PR answers. The plain-HTTP token warning in this
PR is the bridging guardrail.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Introduces authenticated HTTP exposure of server bundles and tarball
parsing; mitigations are strong but misconfiguration (token, HTTP in
prod) or custom hyphen-prefixed hashes could affect deploy seeding.
> 
> **Overview**
> Adds a **zero-infra HTTP rolling-deploy path** so build CI can seed
the Node renderer from the **currently running Rails app** instead of S3
or a custom adapter.
> 
> **Pro** gains `ReactOnRailsPro::RollingDeployAdapters::Http` (manifest
+ per-hash tarball fetch, warn-and-degrade on failure) and
`ReactOnRailsPro::RollingDeploy::BundlesController` with bearer auth
(`secure_compare`, ≥32-byte token enforced at boot), hash allowlisting,
`Cache-Control: no-store`, and gzipped tarballs built via a new stdlib
**`RollingDeploy::Tarball`** helper (flat basenames, regular files only,
200 MB uncompressed cap). Config adds `rolling_deploy_token`,
`rolling_deploy_previous_url`, and `rolling_deploy_mount_path`;
`rolling_deploy_http_adapter?` gates HTTP-specific validation. Routes
are exposed through `BundlesController.draw_routes` (engine auto-mount
is described as follow-up on the branch).
> 
> `RollingDeployCacheStager` now uses shared **`SAFE_HASH_PATTERN`**,
which also **rejects leading-hyphen hashes** (upgrade note in
CHANGELOG). Specs cover auth, tarball companions, adapter HTTP/TLS
warnings, and manifest sanitization. Part 1 of a series—HTTPS-only
enforcement, streaming, and engine mount hardening are called out as
deferred.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e6a3a04. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant