ruact 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +14 -4
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -12
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,233 @@
1
+ # Contributing to ruact
2
+
3
+ Thanks for wanting to change something here. This guide takes you from a fresh clone to an open pull
4
+ request, and everything it asks you to run, you can run — with this repository and nothing else.
5
+
6
+ ## What this repository is
7
+
8
+ `luizcg/ruact` is one repository holding three things that ship together:
9
+
10
+ | Directory | What lives there |
11
+ |---|---|
12
+ | `lib/` | the Ruby library — the ERB preprocessor, the render pipeline, the Flight wire format, the Rails integration, the generators and the Rake tasks |
13
+ | `vendor/javascript/vite-plugin-ruact/` | the Vite plugin — it scans for `"use client"`, emits the client manifest, and generates the typed server-function accessors |
14
+ | `vendor/javascript/ruact-server-functions-runtime/` | the browser-side runtime those generated accessors call |
15
+
16
+ The two JavaScript packages are **bundled, not published**. A host app never installs them from npm:
17
+ `rails generate ruact:install` writes a `vite.config.js` that imports the plugin by absolute path off the
18
+ installed gem. That has one consequence worth stating up front, because it is what makes the rest of this
19
+ document short — **pointing a Rails app at your checkout of the gem points it at your checkout of the Vite
20
+ plugin too.** There is nothing to link separately, and nothing else to clone. This repository has no
21
+ submodules.
22
+
23
+ ## Prerequisites
24
+
25
+ | Tool | Version | Why |
26
+ |---|---|---|
27
+ | Ruby | >= 3.2 | the floor in `ruact.gemspec`; CI runs 3.2 and 3.3 |
28
+ | Node.js | 20 | the JavaScript suite and the asset build only — ruact runs no Node process in production |
29
+ | Bundler | any recent | `gem install bundler` |
30
+
31
+ You do not need a Rails **app** to work on this gem, and you do not install Rails by hand: the `Gemfile`
32
+ pins it to `RAILS_VERSION`, defaulting to 8.0, so `bundle install` brings it. What keeps the suite fast is
33
+ that `spec/support/rails_stub.rb` loads only Rails' core and adds test-only writers on top — the request
34
+ cycle (`action_controller`, `action_view`) is loaded by the handful of specs that actually need it, and by
35
+ nothing else.
36
+
37
+ ## Setup
38
+
39
+ ```bash
40
+ git clone https://github.com/luizcg/ruact.git
41
+ cd ruact
42
+ bundle install
43
+ ```
44
+
45
+ That is the whole setup for a Ruby-side change. If you are touching the Vite plugin or the browser
46
+ runtime, install their dependencies too:
47
+
48
+ ```bash
49
+ cd vendor/javascript/ruact-server-functions-runtime && npm install
50
+ cd ../vite-plugin-ruact && npm ci
51
+ ```
52
+
53
+ The first install is not decoration: the plugin's test run reaches into the runtime package, which keeps
54
+ `react` in its own dependencies.
55
+
56
+ ## Running the checks
57
+
58
+ These are the jobs in [`.github/workflows/ci.yml`](.github/workflows/ci.yml), which runs on every push and
59
+ every pull request. Running them locally first is the difference between one round and four.
60
+
61
+ <!-- ci-jobs:begin -->
62
+
63
+ | CI job | What it checks |
64
+ |---|---|
65
+ | `rspec` | the Ruby suite, on Ruby 3.2/3.3 × Rails 7.0/7.1/7.2/8.0 — eight cells |
66
+ | `rubocop` | style, plus the three custom cops below |
67
+ | `yard` | every public method carries YARD documentation |
68
+ | `js` | the Vite plugin's vitest suite, including its Ruby↔JS byte-parity cases, and a type-level test over the generated accessors |
69
+ | `benchmark` | render-pipeline allocations against a recorded baseline |
70
+ | `name-propagation` | greps the tree for names retired before v0.1.0 |
71
+ | `gate` | that `CHANGELOG.md` gained something if this branch changed what the published gem contains, and that a version bump is one legal semver step to a version nothing has released |
72
+
73
+ <!-- ci-jobs:end -->
74
+
75
+ There is one more job, `release`, which publishes to RubyGems. It never runs on a pull request, and on a
76
+ push to `main` it publishes only when `lib/ruact/version.rb` names a version that carries no tag — which is
77
+ a thing a maintainer does deliberately, in a pull request of its own. See [RELEASING.md](RELEASING.md) for
78
+ the release process itself — this document deliberately describes none of it, so the two cannot drift apart.
79
+
80
+ Locally:
81
+
82
+ ```bash
83
+ bundle exec rspec
84
+ bundle exec rubocop --format github
85
+ rm -rf .yardoc doc && bundle exec yard --fail-on-warning
86
+ bundle exec rake benchmark:memory
87
+ bin/release-gate origin/main HEAD
88
+ ```
89
+
90
+ and, for the `js` job:
91
+
92
+ ```bash
93
+ cd vendor/javascript/vite-plugin-ruact
94
+ npm test
95
+ npm run typecheck
96
+ ```
97
+
98
+ To reproduce one matrix cell other than the default — `RAILS_VERSION` is what the eight `rspec` cells vary,
99
+ and unset means 8.0:
100
+
101
+ ```bash
102
+ RAILS_VERSION=7.2 bundle install && bundle exec rspec
103
+ ```
104
+
105
+ ## Trying your working tree in a real app
106
+
107
+ The suite is fast and the gates are strict, and neither tells you whether your change works in a Rails
108
+ app. This does, in about two minutes. `RUACT` is the absolute path to your clone.
109
+
110
+ ```bash
111
+ export RUACT=/absolute/path/to/your/ruact
112
+
113
+ rails new myapp --skip-javascript
114
+ cd myapp
115
+ printf '\ngem "ruact", path: "%s"\n' "$RUACT" >> Gemfile
116
+ bundle install
117
+ bin/rails generate ruact:install
118
+ bin/dev
119
+ ```
120
+
121
+ No published gem and no published npm package take part: the `path:` source points Bundler at your
122
+ checkout, and the generated `vite.config.js` imports the Vite plugin from inside it. Both halves of your
123
+ change are live.
124
+
125
+ Give it something to render — a component in `app/javascript/components/`:
126
+
127
+ ```jsx
128
+ "use client";
129
+ import { useState } from "react";
130
+
131
+ export function LikeButton({ likes }) {
132
+ const [count, setCount] = useState(likes);
133
+ return <button onClick={() => setCount(count + 1)}>{count} likes</button>;
134
+ }
135
+ ```
136
+
137
+ a controller action, and a view that uses the component by name:
138
+
139
+ ```erb
140
+ <h1>Home</h1>
141
+ <LikeButton likes={@likes} />
142
+ ```
143
+
144
+ `bin/dev` runs Rails and Vite together. Load the page and the button counts up.
145
+
146
+ ## Four gates that fail for reasons your diff does not show
147
+
148
+ Most of CI fails where you expect. These four do not, so they are worth knowing before they surprise you.
149
+
150
+ 1. **`spec/readme_spec.rb`** — `README.md` is pinned **byte for byte** in places: the quick-start block and
151
+ the demo image reference are compared against literals in the spec, the file is allowed exactly one raw
152
+ `<img>`, and a `path:` gem source is forbidden there because a reader who copies one installs nothing.
153
+ Edit the README casually and this goes red.
154
+
155
+ ⚠️ **One of its assertions is repository-wide despite living in a README spec.** It runs `git ls-files`
156
+ over the whole repository and fails if any tracked file is a `.gif`, `.mp4`, `.webm` or `.webp` — media
157
+ committed here would ship inside every built `.gem` and stay in the clone history forever. So **no
158
+ document in this repository can carry an image**, including this one. Media lives with the documentation
159
+ site and is referenced by absolute URL.
160
+
161
+ ⚠️ **YARD reads `README.md`.** Curly braces in its prose — `{@likes}`, `{Foo#bar}` — are link macros to
162
+ YARD, and an unresolvable one turns the `yard` job red for a README edit. Write them as `&#123;` and
163
+ `&#125;` there.
164
+
165
+ 2. **`spec/readme_demo_message_spec.rb`** — the second half of that pin, covering the demo's message.
166
+
167
+ 3. **`spec/benchmarks/render_pipeline_benchmark_spec.rb`** — an **allocation** baseline at ×1.20 tolerance
168
+ against `spec/benchmarks/baseline.json`, shared by matrix cells that legitimately differ by around 9%.
169
+ When it fails, read the header comment at the top of that file before doing anything: it explains how to
170
+ tell a real regression from accumulated drift, and how to regenerate the baseline if it is drift.
171
+
172
+ 4. **Three custom RuboCop cops**, in `lib/rubocop/cop/ruact/` and enabled in `.rubocop.yml` —
173
+ `Ruact/NoSharedState`, `Ruact/NoIoInFlight` and `Ruact/NoExtendSelf`. Between them they say that data
174
+ flows through explicit arguments rather than shared mutable state, that `lib/ruact/flight/**` stays a
175
+ pure value transformation, and that modules expose explicit class methods.
176
+
177
+ Custom cops are the least discoverable failure a newcomer can hit, which is why they are named here —
178
+ but **the cop source is the authority for what each one actually detects**, and each detects a subset of
179
+ the rule it is named for rather than the rule entire. `Ruact/NoExtendSelf` also rejects a bare
180
+ `module_function`, which its name does not suggest. Read the file before assuming a pattern is either
181
+ caught or allowed.
182
+
183
+ And one habit rather than a gate: run `yard` after `rm -rf .yardoc doc`, as the block above does. A warm
184
+ cache hides warnings that CI, which checks out fresh, still reports.
185
+
186
+ ## Where a change goes
187
+
188
+ Every destination below is one you can reach.
189
+
190
+ | Change | Where |
191
+ |---|---|
192
+ | anything under `lib/`, `app/`, `exe/`, `sig/`, `spec/` | **here** — `luizcg/ruact` |
193
+ | a typo in `README.md`, `CHANGELOG.md` or this file | **here** |
194
+ | the Vite plugin | **here**, at `vendor/javascript/vite-plugin-ruact/` — see the note below |
195
+ | the browser runtime for server functions | **here**, at `vendor/javascript/ruact-server-functions-runtime/` |
196
+ | the documentation site at `ruact.dev` | not in this repository — [open an issue](https://github.com/luizcg/ruact/issues) |
197
+ | roadmap, architecture decisions, design | not in this repository — [open an issue](https://github.com/luizcg/ruact/issues) or a discussion |
198
+
199
+ **About the Vite plugin.** There is a `vite-plugin-ruact` package on npm. It is **superseded**: nothing in
200
+ ruact installs it, and the plugin that actually runs is the vendored one in this repository. Send plugin
201
+ changes here.
202
+
203
+ ## Where the design work happens
204
+
205
+ Roadmap, architecture decisions and story planning live in a private repository, so the reasoning behind a
206
+ change is not always visible from here. That does not gate anything: open an issue or a discussion, and the
207
+ outcome of that thinking comes back to you in the thread — you do not need access to propose a change,
208
+ argue for one, or land one.
209
+
210
+ ## Opening the pull request
211
+
212
+ Branch off `main`, keep one topic per pull request, and open it against `luizcg/ruact`. Before you do:
213
+
214
+ - [ ] `bundle exec rspec` passes.
215
+ - [ ] `bundle exec rubocop --format github` is clean.
216
+ - [ ] `rm -rf .yardoc doc && bundle exec yard --fail-on-warning` passes, and every new public method has a
217
+ YARD comment with `@param` and `@return`.
218
+ - [ ] `# frozen_string_literal: true` is the first line of every new Ruby file.
219
+ - [ ] Nothing you added lives in `Ruact::`'s way: gem code stays under `Ruact::`, wire-format code under
220
+ `Ruact::Flight::`, and no Ruby or Rails core class is reopened.
221
+ - [ ] No `extend self` and no bare `module_function` — explicit class methods or a class instead.
222
+ - [ ] If you touched the Vite plugin or the runtime, `npm test` and `npm run typecheck` pass in
223
+ `vendor/javascript/vite-plugin-ruact`.
224
+ - [ ] `bin/release-gate origin/main HEAD` passes. If your branch changes anything that goes inside the
225
+ published gem, it asks [CHANGELOG.md](CHANGELOG.md) for a new bullet under `[Unreleased]` — written
226
+ for a reader who did not see the diff.
227
+
228
+ Describe what the change does and why in the pull request body. If it fixes a reported bug, link the issue.
229
+
230
+ Security issues do **not** go in a pull request — see [SECURITY.md](SECURITY.md) for private reporting.
231
+
232
+ By contributing you agree that your contribution is licensed under the MIT license in
233
+ [LICENSE.txt](LICENSE.txt).
data/README.md CHANGED
@@ -58,7 +58,9 @@ export function PostCard({ post, author }) {
58
58
  }
59
59
  ```
60
60
 
61
- Rails serializes `@post` and `@author` as Ruby values, sends the component tree as a [Flight](https://ruact.dev/docs/concepts/flight-wire-format) payload, and React hydrates it in the browser. No JSON ceremony, no duplicate routes, no Node.js in production.
61
+ Capitalized tag means React. Lowercase stays HTML. `"use client"` is the only directive you need to learn.
62
+
63
+ Your ERB view renders on the server the way it always did, and ruact sends the result to the browser as a React tree — in the same [wire format](https://ruact.dev/docs/concepts/flight-wire-format) React uses for Server Components, implemented in Ruby. The data the view needs is inlined in the page, so React renders immediately, without a fetch. Node builds the bundle with Vite and is needed nowhere else.
62
64
 
63
65
  ## Call Rails from React
64
66
 
@@ -86,12 +88,12 @@ The verb decides — there is no per-action DSL and no second endpoint. The expo
86
88
 
87
89
  ## What you get
88
90
 
89
- Every item below is shipped in this gem at v0.0.9:
91
+ Every item below is shipped in this gem at v0.0.11:
90
92
 
91
93
  - **ERB as server components** — `include Ruact::Controller`, then use your components by name in the views you already have: capitalized is React, lowercase stays HTML. [Docs](https://ruact.dev/docs/concepts/erb-as-server-components)
92
94
  - **`"use client"`** — the one directive that marks a file as client-side. The bundled Vite plugin scans for it and writes the manifest. [Docs](https://ruact.dev/docs/concepts/use-client)
93
- - **Server functions and queries** — `include Ruact::Server` and `Ruact::Query` + `useQuery`, both reachable through a typed module generated from your route table. [Docs](https://ruact.dev/docs/api/server-actions)
94
- - **Props are an allowlist** — `include Ruact::Serializable` + `ruact_props :id, :title`; other columns never cross. [Docs](https://ruact.dev/docs/api/serializable)
95
+ - **Server functions and queries** — `include Ruact::Server` and `Ruact::Query` + `useQuery`, both reachable through a module generated from your route table. A query's declared keywords become an exact TypeScript signature; an action's accessor is typed to accept an object or a `FormData` and resolve whatever the action answers. [Docs](https://ruact.dev/docs/api/server-actions)
96
+ - **An opt-in allowlist for props** — by default a model prop is serialized with `as_json`, so every attribute crosses and the log says so. `include Ruact::Serializable` + `ruact_props :id, :title` makes it an allowlist, and `strict_serialization` (on in production) turns the permissive path into an error rather than a warning. [Docs](https://ruact.dev/docs/api/serializable)
95
97
  - **Validation errors round-trip** — `ruact_errors(record)` hands React `{ title: ["can't be blank"] }` without a serializer. [Docs](https://ruact.dev/docs/api/server-actions)
96
98
  - **Signed record references** — `Ruact.signed_global_id(record, for:, expires_in:)` out, `Ruact.locate_signed(token, for:)` back in; a tampered token is a `400`, not a lookup. [Docs](https://ruact.dev/docs/api/server-actions)
97
99
  - **Client-side navigation** — link interception, scroll restoration and redirect-after-POST, derived from your Rails routes. [Docs](https://ruact.dev/docs/concepts/navigation)
@@ -112,6 +114,12 @@ That is the whole adaptation, and it holds for any tool that outputs standard Re
112
114
 
113
115
  For agents driving the app rather than writing one component: `rails generate ruact:install` writes an `AGENTS.md` into your app, [ruact.dev/llms.txt](https://ruact.dev/llms.txt) serves the same context to tools that fetch from the web, and `bin/rails ruact:doctor -- --json` / `bin/rails ruact:routes -- --json` emit machine-readable output (experimental — `schema_version: 0`, and the `--` separator is required).
114
116
 
117
+ ## Where ruact fits
118
+
119
+ **A good fit:** a Rails monolith that needs real React on real screens — dashboards, editors, admin tools, anything with interaction that ERB alone makes painful — with one team shipping both sides and no appetite for a second application to keep in sync.
120
+
121
+ **Not a fit yet:** pages that must render without JavaScript, or content search engines need to read out of the HTML. ruact renders client-side from a payload inlined in the page; there is no server-rendered HTML for the React tree. That is a real gap, not a roadmap wink — if the page is your SEO surface, keep it in ERB (the two coexist in the same app, per-view) or use something that server-renders.
122
+
115
123
  ## Compatibility
116
124
 
117
125
  | | Version | Where that comes from |
@@ -136,6 +144,8 @@ Everything lives at [ruact.dev](https://ruact.dev):
136
144
 
137
145
  Bug reports and pull requests are welcome at [github.com/luizcg/ruact/issues](https://github.com/luizcg/ruact/issues).
138
146
 
147
+ Setup, the checks a PR runs, and how to try your working tree in a real Rails app: [CONTRIBUTING.md](CONTRIBUTING.md).
148
+
139
149
  Release process: [RELEASING.md](RELEASING.md). Security policy and private reporting: [SECURITY.md](SECURITY.md).
140
150
 
141
151
  ## License
data/RELEASING.md CHANGED
@@ -1,209 +1,195 @@
1
1
  # Releasing ruact
2
2
 
3
- This document describes the complete release process for `ruact`. Following these steps in order enables any maintainer to cut a release independently.
3
+ A release publishes exactly one artifact: the `ruact` gem, to RubyGems.
4
4
 
5
- The gem and the `vite-plugin-ruact` npm package share the same version number and are **always released together** as a single operation.
5
+ The Vite plugin is not a second artifact. It ships **inside** the gem, under `vendor/javascript/`, together with
6
+ the browser runtime; a generated app imports the plugin by filesystem path off the installed gem. Neither
7
+ bundled package is published, so neither is co-versioned with anything — whatever version number their
8
+ `package.json` files carry is internal to the gem and is not a release. The standalone `vite-plugin-ruact`
9
+ package on npm is a superseded artifact from before the plugin was vendored; no ruact release touches it, and
10
+ nothing this document describes puts anything there.
6
11
 
7
- ---
12
+ **The pull request is the release.** You write the version into `lib/ruact/version.rb` and stamp the CHANGELOG
13
+ in the same pull request; merging it is what publishes. There is no switch to turn on beforehand and none to
14
+ remember afterwards, and a merge that carries no new version publishes nothing — its content accumulates under
15
+ `[Unreleased]` until somebody decides to cut a release.
16
+
17
+ That is the whole of this document, and it is why almost every hand step this file used to prescribe has been
18
+ deleted rather than corrected: the workflow builds, uploads and tags, and it writes nothing you would otherwise
19
+ write yourself.
8
20
 
9
- ## Pre-Release Checklist
21
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the checks that run on every push and pull request and how to run
22
+ them locally — this document deliberately describes none of them, so the two cannot drift apart.
23
+
24
+ ---
10
25
 
11
- Before starting, confirm all of the following:
26
+ ## Who owns what
12
27
 
13
- - [ ] All CI jobs are green on `main` (rspec matrix, rubocop, yard, benchmark, e2e)
14
- - [ ] No open issues or PRs labelled `release-blocker`
15
- - [ ] `gem/ruact.gemspec` has no TODO placeholder values (`summary`, `homepage_uri`, `source_code_uri`, `allowed_push_host` must all be set to real values before `gem build` will succeed)
16
- - [ ] You have a RubyGems account with push access to `ruact` and an OTP authenticator configured (MFA is required — `rubygems_mfa_required: true`)
17
- - [ ] You have an npm account with publish access to `vite-plugin-ruact`
18
- - [ ] You have GPG or SSH commit signing configured (recommended)
28
+ | | Owner |
29
+ |---|---|
30
+ | Whether this merge publishes | you by putting a new version in the pull request, or not |
31
+ | The version number | you typed, in `lib/ruact/version.rb` |
32
+ | What the CHANGELOG entry says | you, in the same pull request |
33
+ | The build, the upload to RubyGems, the tag | the `release` job, on the push to `main` |
34
+ | Anything committed to `main` | nobody but a merge — the job pushes a tag and nothing else |
19
35
 
20
36
  ---
21
37
 
22
- ## Version Decision (SemVer)
38
+ ## 1. Decide the version
23
39
 
24
- Given the current version `X.Y.Z`, choose the next version:
40
+ SemVer, against the public API: a breaking change to what consumers call (a renamed public method, a changed
41
+ signature, a removed configuration option, an incompatible change to the Flight wire format) is a major; a
42
+ backwards-compatible feature is a minor; everything else is a patch.
25
43
 
26
- | Change type | Version bump | Example |
27
- |---|---|---|
28
- | Breaking change (see below) | Major: `X+1.0.0` | `0.1.0` `1.0.0` |
29
- | New feature, backwards-compatible | Minor: `X.Y+1.0` | `0.1.0` → `0.2.0` |
30
- | Bug fix, patch | Patch: `X.Y.Z+1` | `0.1.0` → `0.1.1` |
44
+ Nothing computes this for you and nothing defaults. That is deliberate: the number used to be derived from a
45
+ default plus a marker in a commit message, which meant a minor could arrive as a side effect of how a merge was
46
+ worded and the first one would have landed on the version this project has reserved for a milestone it has
47
+ not reached.
31
48
 
32
- **What counts as a breaking change**: any change to the public API that requires consumers to update their code — e.g. renaming a public method, changing method signatures, removing a configuration option, or changing the Flight wire format in a non-backwards-compatible way.
49
+ A minor or a major also moves the supported-versions table in [SECURITY.md](SECURITY.md). Do it in the same
50
+ pull request; nothing checks it.
33
51
 
34
52
  ---
35
53
 
36
- ## Release Steps
37
-
38
- ### 1. Create a release branch
54
+ ## 2. Prepare the release
39
55
 
40
56
  ```bash
41
- git checkout -b release/v{X.Y.Z}
57
+ bin/release X.Y.Z
42
58
  ```
43
59
 
44
- ### 2. Bump the gem version
60
+ It refuses a dirty checkout, and one that is not `main` exactly as the remote has it — a branch is never
61
+ *behind* `main` either, and cutting a release from one would put whatever it carries into the release. It
62
+ refuses a version that is not one step from the current one, and one that is already tagged. Then it writes `lib/ruact/version.rb`, re-resolves `Gemfile.lock` against
63
+ it, moves the accumulated `[Unreleased]` content in [CHANGELOG.md](CHANGELOG.md) under a dated heading, opens a
64
+ fresh empty `[Unreleased]`, rewrites both link references, proves the result against the changelog checks and
65
+ the release gate, commits, pushes, and opens the pull request.
45
66
 
46
- Edit `gem/lib/ruact/version.rb`:
67
+ **The pull-request body it writes is the checklist**, with the version already substituted. There is no
68
+ checklist document in this repository on purpose: a checklist is a second copy of the process in the
69
+ imperative, and it rots the way every other second copy does. This one is generated by the thing that performs
70
+ the process, on every release.
47
71
 
48
- ```ruby
49
- module Ruact
50
- VERSION = "{X.Y.Z}"
51
- end
52
- ```
72
+ You can do all of it by hand — the script performs no step you could not — but then you own the parts it
73
+ proves, and the gate on the pull request is where you find out.
53
74
 
54
- ### 3. Bump the npm package version
75
+ CHANGELOG.md deserves one warning of its own, because it has three readers who share no directory: this
76
+ repository, a `.gem` unpacked on somebody's disk, and the generated changelog page on the documentation site.
77
+ An entry may name a file but must not **link** one relatively, because a target that resolves here 404s in the
78
+ other two. Absolute URLs only, or no link at all.
55
79
 
56
- Edit `packages/vite-plugin-ruact/package.json`:
80
+ ---
57
81
 
58
- ```json
59
- {
60
- "version": "{X.Y.Z}"
61
- }
62
- ```
82
+ ## 3. Merge
63
83
 
64
- ### 4. Update `gem/CHANGELOG.md`
84
+ On the resulting push to `main`, the `release` job — which waits on every other job in the workflow — asks one
85
+ question: **does `lib/ruact/version.rb` name a version that has no tag?**
65
86
 
66
- 1. Move all items under `## [Unreleased]` into a new section `## [{X.Y.Z}] - {YYYY-MM-DD}`.
67
- 2. Add a new empty `## [Unreleased]` section at the very top (above the new release section).
68
- 3. Add or update the link footer at the bottom:
69
- ```
70
- [Unreleased]: https://github.com/luizcg/ruact/compare/v{X.Y.Z}...HEAD
71
- [{X.Y.Z}]: https://github.com/luizcg/ruact/compare/v{PREV}...v{X.Y.Z}
72
- ```
73
- 4. If this release contains breaking changes, ensure the section includes a `[BREAKING]` subsection with a **Migration Guide** (see "Breaking Changes" section below).
87
+ If it does not, the job reports that there is nothing to publish and ends green. That is the ordinary case;
88
+ most merges are not releases.
74
89
 
75
- ### 5. Update `packages/vite-plugin-ruact/CHANGELOG.md`
90
+ If it does, the job authenticates to RubyGems through Trusted Publishing (OIDC) — a short-lived credential
91
+ minted for that run, no stored secret, nothing interactive — then builds the gem, uploads it, and **tags last**.
76
92
 
77
- Same format as step 4 for the npm package changelog.
93
+ **Why the tag comes last.** The tag is the record of a publish that finished. An upload that fails leaves no
94
+ tag, so the version is still untagged, so the next green push to `main` tries again — automatically, with
95
+ nothing to unwind and nobody to remember. The cost of that order is a re-run that meets a version already on
96
+ RubyGems, which is why a duplicate-version rejection is treated as *already done* rather than as a failure.
78
97
 
79
- ### 6. Commit the release
98
+ **Why a question about state rather than about this push.** A trigger that fired on "the version changed in
99
+ this push" would be lost forever whenever no run is created for a push — which happens, merge commits
100
+ included. The version would then say one thing, RubyGems another, and the next release would skip the number
101
+ entirely. Asking about state instead makes the job idempotent and self-healing: run it twice and the second
102
+ does nothing; miss it once and the next green push repairs it.
80
103
 
81
- ```bash
82
- git add gem/lib/ruact/version.rb \
83
- packages/vite-plugin-ruact/package.json \
84
- gem/CHANGELOG.md \
85
- packages/vite-plugin-ruact/CHANGELOG.md
86
- git commit -m "Release v{X.Y.Z}"
87
- ```
104
+ ---
88
105
 
89
- ### 7. Push the release branch and open a PR
106
+ ## 4. Verify
90
107
 
91
108
  ```bash
92
- git push origin release/v{X.Y.Z}
109
+ curl -s https://rubygems.org/api/v1/versions/ruact/latest.json
93
110
  ```
94
111
 
95
- Open a PR from `release/v{X.Y.Z}` `main`. Wait for CI to go green before continuing.
112
+ Use that endpoint. The other one — `/gems/ruact.json` is CDN-cached and keeps serving the previous version
113
+ for minutes after a successful publish, which reads exactly like a release that did not happen.
96
114
 
97
- ### 8. Merge, verify CI, and tag
98
-
99
- Merge the PR. Confirm all CI jobs pass on the merge commit, then tag the verified merge commit:
115
+ ---
100
116
 
101
- ```bash
102
- git checkout main
103
- git pull origin main
104
- git tag v{X.Y.Z}
105
- git push origin v{X.Y.Z}
106
- ```
117
+ ## 5. When nothing was published and nothing said so
107
118
 
108
- > **Why tag after merge?** Tagging after CI passes on the merge commit ensures the tag always points to a verified, releasable commit. Tagging the branch commit before merge risks tagging code that fails CI after merge.
119
+ From the outside, a release that did not happen and one that did look identical until you check RubyGems. Every
120
+ case below shares one recovery, and it is the reason the trigger has the shape it has: **the version is still
121
+ untagged, so the next green push to `main` publishes it.** Nothing has to be unwound and nothing has to be
122
+ remembered.
109
123
 
110
- ### 9. Publish the gem to RubyGems
124
+ **A job the `release` job waits on went red on the push to `main`.** The release job never ran, the merge has
125
+ landed, and no error anywhere says "no release happened". The run that matters is the one on `main`, not the
126
+ one on the pull request:
111
127
 
112
128
  ```bash
113
- cd gem
114
- gem build ruact.gemspec
115
- gem push ruact-{X.Y.Z}.gem
116
- # Enter OTP when prompted (MFA is required)
117
- rm ruact-{X.Y.Z}.gem
129
+ gh run list --branch main -L 3
130
+ gh run rerun <run-id> --failed
118
131
  ```
119
132
 
120
- > **Note**: `spec.files` in the gemspec is populated via `git ls-files -z`. The files `CHANGELOG.md`, `RELEASING.md`, and `SECURITY.md` must be committed to git to appear in the gem tarball. Verify with:
121
- > ```bash
122
- > gem contents ruact-{X.Y.Z} | grep -E 'CHANGELOG|RELEASING|SECURITY'
123
- > ```
133
+ **No run was created for the push at all.** Same state, same repair; there is simply no run to re-run. Push
134
+ something green to `main` — the next merge does it on its own.
124
135
 
125
- ### 10. Publish the npm package
136
+ **The upload failed, or Trusted Publishing rejected the run.** Nothing was tagged, because the tag is written
137
+ after the upload. Fix the cause and let the next green push reconcile it.
126
138
 
127
- ```bash
128
- cd packages/vite-plugin-ruact
129
- npm publish
130
- ```
139
+ **The version is on RubyGems but the API still shows the old one.** CDN cache; see §4.
131
140
 
132
- ### 11. Create a GitHub Release
141
+ **A release was prepared and should not go out.** If it has not merged, close the pull request — nothing was
142
+ released and nothing has to be undone. If it has, see §6.
133
143
 
134
- 1. Go to [Releases](https://github.com/luizcg/ruact/releases/new)
135
- 2. Select tag `v{X.Y.Z}`
136
- 3. Title: `v{X.Y.Z}`
137
- 4. Body: paste the `## [{X.Y.Z}]` section from `gem/CHANGELOG.md`
138
- 5. Publish
144
+ **The suite is red on `main` because the record and the code disagree.** The changelog checks run in a job the
145
+ release job waits on, so once `lib/ruact/version.rb` and `CHANGELOG.md` stop naming the same version, **every**
146
+ later release waits too. The failure names which of the two moved.
139
147
 
140
148
  ---
141
149
 
142
- ## Breaking Changes
143
-
144
- When a release contains a breaking change:
150
+ ## 6. Stopping a release that has already merged
145
151
 
146
- 1. Bump the **major** version (e.g. `0.1.0` `1.0.0`).
147
- 2. Mark the CHANGELOG entry with `[BREAKING]` and include a **Migration Guide** sub-section:
152
+ Publication is irreversible, so there is a stop — a repository variable, read by the job itself:
148
153
 
149
- ```markdown
150
- ## [1.0.0] - YYYY-MM-DD
154
+ ```bash
155
+ gh variable set RUACT_RELEASE_HALT -b stop -R luizcg/ruact
156
+ ```
151
157
 
152
- ### Changed
153
- - [BREAKING] `Ruact::Serializable.ruact_props` now rejects non-Symbol arguments at class-load time
158
+ Any non-empty value stops it. Unset — the normal, permanent state — publishes.
154
159
 
155
- #### Migration Guide
160
+ **A halted run fails red, naming the version it refused.** That is the point of it: the stop is a state
161
+ somebody has to undo, and a stop that produced a quiet, successful-looking run would suppress every later
162
+ release invisibly. This one reddens `main` on the next merge instead, which is a thing you find out about.
156
163
 
157
- **Before:**
158
- ```ruby
159
- class Post
160
- include Ruact::Serializable
161
- ruact_props "id", "title" # strings silently coerced
162
- end
163
- ```
164
- **After:**
165
- ```ruby
166
- class Post
167
- include Ruact::Serializable
168
- ruact_props :id, :title # must be Symbols
169
- end
170
- ```
171
- ```
164
+ ```bash
165
+ gh variable delete RUACT_RELEASE_HALT -R luizcg/ruact
166
+ ```
172
167
 
173
- 3. Announce the breaking change prominently in the GitHub Release body.
168
+ The version is still untagged while the halt is in place, so clearing it does not require another bump: the
169
+ next green push publishes what was held.
174
170
 
175
171
  ---
176
172
 
177
- ## Rollback
173
+ ## 7. Rollback
178
174
 
179
- If a release contains a critical defect and must be pulled:
175
+ If a published version has a critical defect:
180
176
 
181
- **RubyGems** (within 30 days):
182
177
  ```bash
183
- gem yank ruact -v {X.Y.Z}
178
+ gem yank ruact -v X.Y.Z
184
179
  ```
185
180
 
186
- **npm** (within 72 hours):
187
- ```bash
188
- npm unpublish vite-plugin-ruact@{X.Y.Z}
189
- ```
181
+ This is the one RubyGems command still run by a person rather than by the workflow, and it is the one place the
182
+ "nothing interactive" of §3 does not apply: the gem declares `rubygems_mfa_required`, so yanking needs your own
183
+ RubyGems credential and a one-time code. The workflow's OIDC credential is minted for its own run and is no
184
+ help here.
190
185
 
191
- After yanking, cut a patch release (`{X.Y.Z+1}`) with the fix immediately. Do not leave the version yanked without a replacement.
186
+ Then cut a patch release with the fix immediately. A yanked version with no replacement leaves anyone who
187
+ pinned it with nothing to move to. There is nothing to unpublish anywhere else — this process publishes to
188
+ RubyGems only.
192
189
 
193
190
  ---
194
191
 
195
- ## Troubleshooting
196
-
197
- **`gem push` fails with "MFA required"**: Run `gem signin` first and ensure your OTP authenticator is set up at https://rubygems.org/settings/edit.
192
+ ## Where this ends
198
193
 
199
- **Files missing from gem tarball**: The gemspec uses `git ls-files -z`. Ensure all new files (e.g. `CHANGELOG.md`) are committed to git before running `gem build`.
200
-
201
- **CI fails on release branch**: Fix the issue on the release branch and push the fix. Wait for CI to pass before tagging. If you already pushed a tag pointing to a broken commit, delete it and recreate after the fix:
202
- ```bash
203
- git tag -d v{X.Y.Z} # delete local tag
204
- git push origin :refs/tags/v{X.Y.Z} # delete remote tag
205
- # fix the issue, merge, then re-tag from the correct commit
206
- git checkout main && git pull origin main
207
- git tag v{X.Y.Z} && git push origin v{X.Y.Z}
208
- ```
209
- Do not use `git push --force` on tags — force-pushing a tag rewrite history for anyone who has already fetched it.
194
+ The gem is published and verified. The rest of a release the private planning side is outside this
195
+ repository and outside this document.