actionable-rb 1.9.0
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.
- checksums.yaml +7 -0
- data/.claude-plugin/marketplace.json +15 -0
- data/.claude-plugin/plugin.json +9 -0
- data/.rspec +3 -0
- data/AGENTS.md +150 -0
- data/CHANGELOG.md +766 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +555 -0
- data/UPGRADING.md +957 -0
- data/USAGE.md +1291 -0
- data/docs/getting_started.md +611 -0
- data/lib/actionable/action.rb +1220 -0
- data/lib/actionable/batch_result.rb +92 -0
- data/lib/actionable/errors.rb +36 -0
- data/lib/actionable/history.rb +91 -0
- data/lib/actionable/input_dispatch.rb +94 -0
- data/lib/actionable/job.rb +157 -0
- data/lib/actionable/measurement.rb +64 -0
- data/lib/actionable/rails/after_commit.rb +142 -0
- data/lib/actionable/rails/proxy_validator.rb +59 -0
- data/lib/actionable/rails/transactions.rb +95 -0
- data/lib/actionable/rails.rb +24 -0
- data/lib/actionable/railtie.rb +10 -0
- data/lib/actionable/rbs.rb +502 -0
- data/lib/actionable/registry.rb +64 -0
- data/lib/actionable/result.rb +166 -0
- data/lib/actionable/results/failure.rb +43 -0
- data/lib/actionable/results/skipped.rb +30 -0
- data/lib/actionable/results/success.rb +14 -0
- data/lib/actionable/rspec/coverage/report.rb +264 -0
- data/lib/actionable/rspec/coverage/tracker.rb +330 -0
- data/lib/actionable/rspec/coverage.rb +45 -0
- data/lib/actionable/rspec/matchers.rb +266 -0
- data/lib/actionable/rspec/stubs.rb +250 -0
- data/lib/actionable/rspec.rb +40 -0
- data/lib/actionable/rubocop/config.yml +8 -0
- data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
- data/lib/actionable/rubocop/plugin.rb +31 -0
- data/lib/actionable/rubocop.rb +25 -0
- data/lib/actionable/runner.rb +300 -0
- data/lib/actionable/steps/action.rb +76 -0
- data/lib/actionable/steps/base.rb +107 -0
- data/lib/actionable/steps/case.rb +124 -0
- data/lib/actionable/steps/method.rb +39 -0
- data/lib/actionable/steps.rb +27 -0
- data/lib/actionable/value_match.rb +22 -0
- data/lib/actionable/verb_position.rb +574 -0
- data/lib/actionable/version.rb +6 -0
- data/lib/actionable-rb.rb +6 -0
- data/lib/actionable.rb +59 -0
- data/sig/actionable/dsl.rbs +42 -0
- data/sig/actionable/rails.rbs +16 -0
- data/sig/actionable.rbs +2593 -0
- data/skills/actionable/SKILL.md +214 -0
- metadata +132 -0
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
# Upgrading Actionable
|
|
2
|
+
|
|
3
|
+
<!-- upgrading-range: 1.5.0..1.9.0 -->
|
|
4
|
+
|
|
5
|
+
This guide takes an app from any **1.5.x** release to **1.9.0**, one version at
|
|
6
|
+
a time or [straight through](#straight-from-15-to-18). It lists every change a
|
|
7
|
+
caller can see, not just the breaking ones. Each entry has the same four parts:
|
|
8
|
+
|
|
9
|
+
- **Symptom**: the exception and message shape, or the spec that fails.
|
|
10
|
+
- **Find**: a copy-pasteable search, or the tool that finds it when no
|
|
11
|
+
search is reliable.
|
|
12
|
+
- **Fix**: before and after.
|
|
13
|
+
- **Why**: one line.
|
|
14
|
+
|
|
15
|
+
The `rg` patterns search `app lib` (code), `spec` and `sig`; point them at your
|
|
16
|
+
own directories and drop any that don't exist. Patterns with `-P` need ripgrep
|
|
17
|
+
built with PCRE2. Check first, in the shell that will run them, with
|
|
18
|
+
`rg --pcre2-version`; an agent's `bash -c` may not see a shell function or alias
|
|
19
|
+
that wraps `rg`. A pattern finds *candidates*; read each hit.
|
|
20
|
+
|
|
21
|
+
**Which Finds to run.** Most changes raise or fail a spec, so the suite finds
|
|
22
|
+
them; their Finds cover code the suite doesn't reach. Seven change behaviour
|
|
23
|
+
without raising, so run their Finds even on a green suite: the
|
|
24
|
+
[nested savepoint](#a-nested-transactional-action-takes-its-own-savepoint),
|
|
25
|
+
[bang verbs in hooks](#a-bang-verb-inside-a-lifecycle-hook-sets-the-result),
|
|
26
|
+
[nested output](#a-nested-action-no-longer-overwrites-parent-fields-it-didnt-set),
|
|
27
|
+
[RSpec stubs](#rspec-stubs-absorb-only-the-fields-you-give-them),
|
|
28
|
+
[redeclared steps](#redeclaring-a-step-or-hook-overrides-it-last-wins),
|
|
29
|
+
[`always` after a halt](#always-hooks-run-even-after-an-outcome-hook-halts) and
|
|
30
|
+
[`case_step` branch guards](#case_step-branch-guards-are-honoured). Every pattern has been checked against the "before" snippet it
|
|
31
|
+
targets. [CHANGELOG.md](CHANGELOG.md) has the
|
|
32
|
+
full list of changes, including internal ones.
|
|
33
|
+
|
|
34
|
+
Agents: follow the numbered procedure in [AGENTS.md](AGENTS.md#upgrading)
|
|
35
|
+
(or the bundled skill), which runs this guide step by step.
|
|
36
|
+
|
|
37
|
+
## TL;DR
|
|
38
|
+
|
|
39
|
+
**Who must act:** every app moving to 1.8.0 (two new checks raise on code that
|
|
40
|
+
used to run), anyone who keeps hand-written RBS next to `Actionable::RBS`
|
|
41
|
+
output, and Rails apps that nest `transactional` actions. **Already on 1.8.1?**
|
|
42
|
+
Skip to [1.8.1 → 1.8.2](#181--182): one silent change (`case_step` branch
|
|
43
|
+
guards) and two narrow ones. **Already on 1.8.2?** Skip to
|
|
44
|
+
[1.8.2 → 1.9.0](#182--190): the gem is renamed `actionable-rb`.
|
|
45
|
+
|
|
46
|
+
1. **Bump field_struct first.** actionable >= 1.6 requires `field_struct >=
|
|
47
|
+
0.12` (1.5.2 needs `>= 0.11`). Upgrade it and follow field_struct's
|
|
48
|
+
[UPGRADING.md](https://github.com/Paymentbox-com/field_struct/blob/main/UPGRADING.md)
|
|
49
|
+
until your suite is green.
|
|
50
|
+
2. **Bump actionable** to `v1.9.0` in the Gemfile, renaming the line to
|
|
51
|
+
`gem 'actionable-rb'` ([why](#the-gem-is-named-actionable-rb)).
|
|
52
|
+
3. **Fix verb-position offenses.** If you need a green baseline first, run the
|
|
53
|
+
suite once with `Actionable.verb_position_check = false`, then turn it back
|
|
54
|
+
on. Enable the [`Actionable/VerbNotLast` cop](#a-plain-verb-must-end-its-step-or-hook-method)
|
|
55
|
+
in `.rubocop.yml` to list every offense up front. Fix
|
|
56
|
+
[non-identifier codes](#fail--skip-codes-must-be-identifiers) at the same
|
|
57
|
+
time: that check has no switch.
|
|
58
|
+
4. **Regenerate the RBS you own** with `Actionable::RBS.generate`, then delete
|
|
59
|
+
hand-written declarations it now covers (see the [1.6.0 entries](#152--160)).
|
|
60
|
+
5. **Opting in to job retries** (`honor_retry_metadata`)? Under ActiveJob, also
|
|
61
|
+
declare `retry_on Actionable::Job::RetryableFailure`.
|
|
62
|
+
6. **Using `after_commit`?** It needs ActiveRecord >= 7.2. The rest of the
|
|
63
|
+
Rails adapter is tested on 7.1.
|
|
64
|
+
|
|
65
|
+
**Expected effort:** the fixes take minutes to an hour for most apps (what the
|
|
66
|
+
cop and the code check report). Longer only if you keep hand-written RBS for
|
|
67
|
+
your actions or nest `transactional` actions inside your own transactions. On
|
|
68
|
+
top of that come the full suite runs: a baseline, one after the field_struct
|
|
69
|
+
bump, one after this one, and a final run with the check on. Budget 4× your
|
|
70
|
+
suite's wall time.
|
|
71
|
+
|
|
72
|
+
## Straight from 1.5 to 1.8
|
|
73
|
+
|
|
74
|
+
You can jump in one bump. Work in this order, so each step starts from a green
|
|
75
|
+
run:
|
|
76
|
+
|
|
77
|
+
1. Bump field_struct to >= 0.12 (step 1 above). Bundler refuses the actionable
|
|
78
|
+
bump until you do: `Bundler could not find compatible versions for gem
|
|
79
|
+
"field_struct"`.
|
|
80
|
+
2. Bump actionable to `v1.9.0` as `gem 'actionable-rb'`. Set `Actionable.verb_position_check = false`
|
|
81
|
+
in `spec_helper` (or a `spec/support` file) for now, not in an initializer:
|
|
82
|
+
an initializer also switches the check off in production, and that is easy
|
|
83
|
+
to forget to undo. Use an initializer only if you must deploy before every
|
|
84
|
+
offense is fixed (see [the verb entry](#a-plain-verb-must-end-its-step-or-hook-method)).
|
|
85
|
+
3. Run the suite. Work through the [1.5.2](#15x--152) and
|
|
86
|
+
[1.7.0](#160--170) entries: they change runtime behaviour (savepoints,
|
|
87
|
+
hook halts, nested output, redeclared steps, `always` hooks).
|
|
88
|
+
4. Enable the cop, fix every offense, and remove the
|
|
89
|
+
`verb_position_check = false` line. Fix the
|
|
90
|
+
[identifier codes](#fail--skip-codes-must-be-identifiers).
|
|
91
|
+
5. Regenerate your RBS and work through the [1.6.0](#152--160) entries and
|
|
92
|
+
[adapter signatures](#adapter-signatures-now-ship-in-sig).
|
|
93
|
+
6. Work through the [1.8.2](#181--182) entries: a `case_step` branch guard
|
|
94
|
+
now takes effect, and that doesn't raise, so run its Find.
|
|
95
|
+
7. If `.rubocop.yml` loads the cop with `plugins:`, its key is
|
|
96
|
+
[`actionable-rb:`](#the-gem-is-named-actionable-rb) from 1.9.0.
|
|
97
|
+
8. Run the suite with the check on. Anything still failing:
|
|
98
|
+
[bisect by version](#if-something-else-breaks).
|
|
99
|
+
|
|
100
|
+
## Symptom index
|
|
101
|
+
|
|
102
|
+
| You see | Version | Entry |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| Bundler: `could not find compatible versions for gem "field_struct"` | 1.5.2, 1.6.0 | [>= 0.11](#requires-field_struct--011), [>= 0.12](#requires-field_struct--012) |
|
|
105
|
+
| A failed nested `transactional` action's rows are gone | 1.5.2 | [Savepoint](#a-nested-transactional-action-takes-its-own-savepoint) |
|
|
106
|
+
| `fail!` in `on_success` now fails the run / rolls back / retries the job | 1.5.2 | [Bang verb in a hook](#a-bang-verb-inside-a-lifecycle-hook-sets-the-result) |
|
|
107
|
+
| A parent field keeps its value after `step Child` | 1.5.2 | [Nested absorb](#a-nested-action-no-longer-overwrites-parent-fields-it-didnt-set), [stubs](#rspec-stubs-absorb-only-the-fields-you-give-them) |
|
|
108
|
+
| `DefinitionError: … declares step(s) calling :print but does not implement them` | 1.5.2 | [Core method names](#a-step-or-hook-named-after-a-core-ruby-method-is-refused) |
|
|
109
|
+
| `DefinitionError: … set @result to a …` | 1.5.2 | [`@result`](#assigning-result-in-a-step-raises-a-clear-error) |
|
|
110
|
+
| `errors[:output]` gone from a failure or skip | 1.5.2 | [Spurious output error](#failures-and-skips-no-longer-carry-a-spurious-errorsoutput) |
|
|
111
|
+
| `RBS::InstanceVariableDuplicationError` | 1.6.0 | [Output ivars](#generated-rbs-owns-the-output-instance-variables) |
|
|
112
|
+
| `RBS::SuperclassMismatchError` | 1.6.0 | [Superclass chain](#generated-rbs-declares-the-real-superclass-chain) |
|
|
113
|
+
| `ArgumentError: X::InputSchema is already defined …` | 1.6.0 | [`InputSchema`](#inputschema-is-a-reserved-name-on-actions-with-a-fixed-input) |
|
|
114
|
+
| New Steep errors at call sites | 1.6.0 | [Stricter types](#generated-types-are-stricter) |
|
|
115
|
+
| A redeclared step runs different options | 1.7.0 | [Redeclaration](#redeclaring-a-step-or-hook-overrides-it-last-wins) |
|
|
116
|
+
| An `always` hook runs after a hook's `fail!` | 1.7.0 | [`always` after halt](#always-hooks-run-even-after-an-outcome-hook-halts) |
|
|
117
|
+
| `RBS::DuplicatedMethodDefinitionError` for `transactional`, `perform_actionable` … | 1.7.0 | [Adapter signatures](#adapter-signatures-now-ship-in-sig) |
|
|
118
|
+
| `describe[:hooks]` has an extra `after_commit:` key | 1.7.0 | [`describe`](#describe-lists-after_commit-hooks-under-the-rails-adapter) |
|
|
119
|
+
| ``DefinitionError: … calls `fail` with more code after it …`` | 1.8.0 | [Verb position](#a-plain-verb-must-end-its-step-or-hook-method) |
|
|
120
|
+
| `ArgumentError: … code "…" is not an identifier …` | 1.8.0 | [Identifier codes](#fail--skip-codes-must-be-identifiers) |
|
|
121
|
+
| A guarded `case_step` branch no longer runs | 1.8.2 | [Branch guards](#case_step-branch-guards-are-honoured) |
|
|
122
|
+
| `ArgumentError: … both name …Output in the generated RBS` | 1.8.2 | [Output class names](#actionablerbs-refuses-codes-that-share-an-output-class-name) |
|
|
123
|
+
| ``DefinitionError: … calls `fail` …`` for an aliased step | 1.8.2 | [Aliased step](#the-verb-check-reads-an-aliased-steps-original-def) |
|
|
124
|
+
| `Could not find gem 'actionable' in …` from Bundler, or RuboCop ``Failed to load plugin `actionable` `` | 1.9.0 | [Gem renamed](#the-gem-is-named-actionable-rb) |
|
|
125
|
+
|
|
126
|
+
## 1.5.x → 1.5.2
|
|
127
|
+
|
|
128
|
+
### Requires field_struct >= 0.11
|
|
129
|
+
<!-- upgrading: field-struct-0-11 -->
|
|
130
|
+
|
|
131
|
+
**Symptom.** `bundle update actionable` fails: `Bundler could not find
|
|
132
|
+
compatible versions for gem "field_struct"`.
|
|
133
|
+
|
|
134
|
+
**Find.**
|
|
135
|
+
```sh
|
|
136
|
+
rg -n "gem ['\"](field_struct|actionable)['\"]" Gemfile
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Fix.** Raise the field_struct pin in the same change:
|
|
140
|
+
```ruby
|
|
141
|
+
# before
|
|
142
|
+
gem 'field_struct', git: 'https://github.com/Paymentbox-com/field_struct', tag: 'v0.10.0'
|
|
143
|
+
# after (0.12.0 if you are going on to 1.6+)
|
|
144
|
+
gem 'field_struct', git: 'https://github.com/Paymentbox-com/field_struct', tag: 'v0.12.0'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Why.** 1.5.2 is tested against field_struct 0.11's numeric and input fixes.
|
|
148
|
+
|
|
149
|
+
### A nested `transactional` action takes its own savepoint
|
|
150
|
+
<!-- upgrading: transactional-savepoint -->
|
|
151
|
+
|
|
152
|
+
**Symptom.** Inside an open transaction (your own `Model.transaction do`, or a
|
|
153
|
+
transactional parent action), a `transactional` action that ends in a
|
|
154
|
+
`Failure` now rolls back its own writes. A spec that expected those rows to
|
|
155
|
+
exist after the outer block commits now finds them missing.
|
|
156
|
+
|
|
157
|
+
**Find.** List the transactional actions, then check which run inside another
|
|
158
|
+
transaction:
|
|
159
|
+
```sh
|
|
160
|
+
rg -n '^\s*transactional\b' app lib
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Fix.** Usually nothing: the new behaviour is the correct one. To keep the old
|
|
164
|
+
joining behaviour for one action:
|
|
165
|
+
```ruby
|
|
166
|
+
# before (1.5.1 joined the outer transaction)
|
|
167
|
+
transactional model: :invoice
|
|
168
|
+
# after, only if you really want a failed run's writes to commit with the outer transaction
|
|
169
|
+
transactional model: :invoice, requires_new: false
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Why.** A recorded `Failure` committed its rows through the outer transaction,
|
|
173
|
+
which ignored the action's rollback.
|
|
174
|
+
|
|
175
|
+
### A bang verb inside a lifecycle hook sets the result
|
|
176
|
+
<!-- upgrading: hook-bang-verb -->
|
|
177
|
+
|
|
178
|
+
**Symptom.** `fail!` / `skip!` / `succeed!` in `on_success` / `on_failure` /
|
|
179
|
+
`on_skip` / `always` used to be dropped. Now the run ends with that hook's
|
|
180
|
+
result: a spec expecting `Success` gets the `Failure`, a `transactional`
|
|
181
|
+
action rolls back, and a job retries or discards. A nested action used as a
|
|
182
|
+
hook (`on_success AuditPayment`) that fails now fails the run.
|
|
183
|
+
|
|
184
|
+
**Find.** No single pattern. List the hook declarations, then read those
|
|
185
|
+
methods for a bang verb:
|
|
186
|
+
```sh
|
|
187
|
+
rg -n '^\s*(on_success|on_failure|on_skip|always)\s+:' app lib # hook methods
|
|
188
|
+
rg -n '^\s*(on_success|on_failure|on_skip|always)\s+[A-Z]' app lib # nested actions as hooks
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Fix.** Keep the bang verb if the hook's failure should fail the run (that is
|
|
192
|
+
what the docs always said). If it shouldn't, record nothing: log, or `halt!`
|
|
193
|
+
to stop the remaining hooks and keep the result.
|
|
194
|
+
```ruby
|
|
195
|
+
# before: silently ignored, the run stayed a Success
|
|
196
|
+
def notify
|
|
197
|
+
fail!(:notify_failed) unless mailer.ok?
|
|
198
|
+
end
|
|
199
|
+
# after, when a mailer problem must not fail the payment
|
|
200
|
+
def notify
|
|
201
|
+
logger.warn('receipt not sent') unless mailer.ok?
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Why.** A hook's `fail!` must not commit a transaction or ack a job as a success.
|
|
206
|
+
|
|
207
|
+
### A nested action no longer overwrites parent fields it didn't set
|
|
208
|
+
<!-- upgrading: nested-absorb-set-fields -->
|
|
209
|
+
|
|
210
|
+
**Symptom.** After `step Child`, a parent field the child left nil (or at its
|
|
211
|
+
default) keeps the parent's value instead of being reset. A spec expecting the
|
|
212
|
+
nil now sees the old value.
|
|
213
|
+
|
|
214
|
+
**Find.** No reliable pattern; review nested steps without `expose:` whose child
|
|
215
|
+
shares output field names with the parent:
|
|
216
|
+
```sh
|
|
217
|
+
rg -n '^\s*step\s+[A-Z]' app lib
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Fix.** Have the child set the field explicitly; an assigned ivar (even `nil`)
|
|
221
|
+
counts as set:
|
|
222
|
+
|
|
223
|
+
<!-- doctest -->
|
|
224
|
+
```ruby
|
|
225
|
+
class ClearNote < Actionable::Action
|
|
226
|
+
output { optional :note, :string }
|
|
227
|
+
step :clear
|
|
228
|
+
def clear = @note = nil # assigned, so the parent absorbs the nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
class Edit < Actionable::Action
|
|
232
|
+
output { optional :note, :string }
|
|
233
|
+
step :draft
|
|
234
|
+
step ClearNote
|
|
235
|
+
def draft = @note = 'draft'
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
Edit.run.note # => nil
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**Why.** Copying every declared field wiped parent values the child never touched.
|
|
242
|
+
|
|
243
|
+
### RSpec stubs absorb only the fields you give them
|
|
244
|
+
<!-- upgrading: stub-set-fields -->
|
|
245
|
+
|
|
246
|
+
**Symptom.** A parent spec that stubs a child with partial output
|
|
247
|
+
(`allow_actionable_success(Child, total: 5)`) no longer gets the child's other
|
|
248
|
+
fields reset to nil; it keeps the parent's values, as a real run does.
|
|
249
|
+
|
|
250
|
+
**Find.**
|
|
251
|
+
```sh
|
|
252
|
+
rg -nP '\b(?:allow|stub)_actionable_(?:success|skip)\b' spec
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Fix.** Pass every field the parent should receive, `nil` included:
|
|
256
|
+
```ruby
|
|
257
|
+
# before: relied on the stub resetting :note
|
|
258
|
+
allow_actionable_success(Child, total: 5)
|
|
259
|
+
# after
|
|
260
|
+
allow_actionable_success(Child, total: 5, note: nil)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Why.** Stubs now behave like the real runs they stand in for.
|
|
264
|
+
|
|
265
|
+
### A step or hook named after a core Ruby method is refused
|
|
266
|
+
<!-- upgrading: core-method-steps -->
|
|
267
|
+
|
|
268
|
+
**Symptom.** First run raises
|
|
269
|
+
`Actionable::DefinitionError: Report declares step(s) calling :print but does not implement them`.
|
|
270
|
+
|
|
271
|
+
**Find.** The runtime check is authoritative; this lists the common names:
|
|
272
|
+
```sh
|
|
273
|
+
rg -nP '^\s*(?:step|case_step|on_success|on_failure|on_skip|always)\s+:(?:puts|print|p|pp|exit|exit!|abort|sleep|format|sprintf|printf|warn|raise|system|exec|spawn|fork|open|select|test|loop|rand|srand|gets|require|load|eval|binding|caller|catch|throw|trap|display|hash|freeze|dup|clone|inspect|tap|then|itself|send|method|methods|class|name|object_id)\b' app lib
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Fix.** Implement the method on the action, or rename the step:
|
|
277
|
+
```ruby
|
|
278
|
+
# before: called Kernel#print (or Kernel#exit, which ended the process)
|
|
279
|
+
step :print
|
|
280
|
+
# after
|
|
281
|
+
step :print_report
|
|
282
|
+
def print_report = ...
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Why.** Steps are called with `__send__`, so `always :exit` could reach `Kernel#exit`.
|
|
286
|
+
|
|
287
|
+
### Assigning `@result` in a step raises a clear error
|
|
288
|
+
<!-- upgrading: result-ivar -->
|
|
289
|
+
|
|
290
|
+
**Symptom.** `Actionable::DefinitionError: Lookup set @result to a Account; @result is reserved for the run's result, so rename the instance variable`
|
|
291
|
+
(before: a `NoMethodError` deep in the runner).
|
|
292
|
+
|
|
293
|
+
**Find.**
|
|
294
|
+
```sh
|
|
295
|
+
rg -n '@result\s*(\|\|)?=' app lib
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**Fix.**
|
|
299
|
+
```ruby
|
|
300
|
+
# before
|
|
301
|
+
def find = @result = Account.find(input.id)
|
|
302
|
+
# after
|
|
303
|
+
def find = @account = Account.find(input.id)
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**Why.** `@result` holds the run's result.
|
|
307
|
+
|
|
308
|
+
Assigning an `Actionable::Result` (a `Failure`, `Skipped` or `Success`) is not
|
|
309
|
+
refused, from a step or from a helper a step calls, and `halt!` is the
|
|
310
|
+
documented way to stop after it ([USAGE](USAGE.md)'s verb table). So an app base
|
|
311
|
+
class that builds a failure carrying ActiveModel errors keeps working:
|
|
312
|
+
```ruby
|
|
313
|
+
def fail_invalid!(record)
|
|
314
|
+
@result = Actionable::Failure.new(code: :invalid, message: 'Record is invalid', metadata: {}, output: {})
|
|
315
|
+
record.errors.each { |error| @result.errors.add(error.attribute, error.message) }
|
|
316
|
+
halt!
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
Only a non-`Result` value raises.
|
|
320
|
+
|
|
321
|
+
### Failures and skips no longer carry a spurious `errors[:output]`
|
|
322
|
+
<!-- upgrading: no-spurious-output-error -->
|
|
323
|
+
|
|
324
|
+
**Symptom.** A spec asserting `errors[:output] == ["is invalid"]` on a failure
|
|
325
|
+
or skip of an untyped action fails: the key is gone.
|
|
326
|
+
|
|
327
|
+
**Find.**
|
|
328
|
+
```sh
|
|
329
|
+
rg -nP "errors\[:output\]|output:\s*\[\s*['\"]is invalid" spec
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Fix.** Delete the assertion; errors the failure recorded itself are still there.
|
|
333
|
+
```ruby
|
|
334
|
+
# before
|
|
335
|
+
expect(result.errors).to include(output: ['is invalid'])
|
|
336
|
+
# after: assert on the errors your fail call recorded
|
|
337
|
+
expect(result.errors).to include(card: ['expired'])
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**Why.** Best-effort output on a failure or skip is unvalidated by design.
|
|
341
|
+
|
|
342
|
+
### Also fixed in 1.5.2 (no action expected)
|
|
343
|
+
|
|
344
|
+
- `succeed` / `skip` kwargs survive lifecycle hooks: a run that used to end as
|
|
345
|
+
`Failure(:invalid_output)` because a hook dropped a kwarg now succeeds.
|
|
346
|
+
<!-- upgrading: hook-kwargs -->
|
|
347
|
+
- Private step, hook and `case_step` methods work (they raised `NoMethodError`).
|
|
348
|
+
- `Action.input` without a block reads the schema instead of replacing it.
|
|
349
|
+
|
|
350
|
+
## 1.5.2 → 1.6.0
|
|
351
|
+
|
|
352
|
+
No runtime behaviour changes. Everything here is about **generated RBS** and
|
|
353
|
+
the hand-written sig files that sit next to it. Regenerate first
|
|
354
|
+
(`Actionable::RBS.generate(YourAction)`, one file per action), then run
|
|
355
|
+
`rbs validate` (or Steep) and work through what it reports.
|
|
356
|
+
|
|
357
|
+
### Requires field_struct >= 0.12
|
|
358
|
+
<!-- upgrading: field-struct-0-12 -->
|
|
359
|
+
|
|
360
|
+
**Symptom.** `Bundler could not find compatible versions for gem "field_struct"`.
|
|
361
|
+
|
|
362
|
+
**Find.**
|
|
363
|
+
```sh
|
|
364
|
+
rg -n "gem ['\"](field_struct|actionable)['\"]" Gemfile
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Fix.** Pin `tag: 'v0.12.0'` (or later) and follow field_struct's
|
|
368
|
+
[UPGRADING.md](https://github.com/Paymentbox-com/field_struct/blob/main/UPGRADING.md)
|
|
369
|
+
before bumping actionable.
|
|
370
|
+
|
|
371
|
+
**Why.** Generated action RBS takes every field type from
|
|
372
|
+
`FieldStruct::RBS.type_for` (0.12).
|
|
373
|
+
|
|
374
|
+
### Generated RBS owns the output instance variables
|
|
375
|
+
<!-- upgrading: rbs-output-ivars -->
|
|
376
|
+
|
|
377
|
+
**Symptom.** `rbs validate`:
|
|
378
|
+
``Duplicated instance variable name `@total` in `::Total` (RBS::InstanceVariableDuplicationError)``.
|
|
379
|
+
|
|
380
|
+
**Find.** Your hand-written ivar declarations; delete those named after an
|
|
381
|
+
output field:
|
|
382
|
+
```sh
|
|
383
|
+
rg -n '^\s*@\w+\s*:' sig
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Fix.**
|
|
387
|
+
```rbs
|
|
388
|
+
# before (your sig/total.rbs)
|
|
389
|
+
class Total < ::ApplicationAction
|
|
390
|
+
@total: ::Integer
|
|
391
|
+
end
|
|
392
|
+
# after: delete the declaration; the generated file has `@total: ::Integer?`
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**Why.** A step that assigns `@total = ...` now type-checks without hand-written sigs.
|
|
396
|
+
|
|
397
|
+
### Generated RBS declares the real superclass chain
|
|
398
|
+
<!-- upgrading: rbs-superclass-chain -->
|
|
399
|
+
|
|
400
|
+
**Symptom.** `rbs validate`:
|
|
401
|
+
`Superclass mismatch: ::Billing::BaseAction (RBS::SuperclassMismatchError)`,
|
|
402
|
+
or a duplicate declaration error for a namespace you declared as `module`
|
|
403
|
+
that Ruby defines as a class.
|
|
404
|
+
|
|
405
|
+
**Find.** Your hand-written bases declared straight under
|
|
406
|
+
`Actionable::Action`; compare each with Ruby's `superclass`:
|
|
407
|
+
```sh
|
|
408
|
+
rg -nP '^\s*class\s+[\w:]+\s*<\s*(?:::)?Actionable::Action\b' sig
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**Fix.** Match Ruby's ancestry, or delete the declaration (the generated file
|
|
412
|
+
declares every named ancestor up to `Actionable::Action`):
|
|
413
|
+
```rbs
|
|
414
|
+
# before (Ruby says: class Billing::BaseAction < ApplicationAction)
|
|
415
|
+
class Billing::BaseAction < ::Actionable::Action
|
|
416
|
+
end
|
|
417
|
+
# after
|
|
418
|
+
class Billing::BaseAction < ::ApplicationAction
|
|
419
|
+
end
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Why.** Actions under an intermediate base were declared `< ::Actionable::Action`,
|
|
423
|
+
hiding their bases from the type checker. See
|
|
424
|
+
[USAGE, Base actions](USAGE.md#base-actions).
|
|
425
|
+
|
|
426
|
+
### `InputSchema` is a reserved name on actions with a fixed input
|
|
427
|
+
<!-- upgrading: rbs-input-schema -->
|
|
428
|
+
|
|
429
|
+
**Symptom.** `Actionable::RBS.generate` raises
|
|
430
|
+
`ArgumentError: Import::InputSchema is already defined (in Import); Actionable::RBS.generate names the generated input type InputSchema, so rename that constant`.
|
|
431
|
+
|
|
432
|
+
**Find.**
|
|
433
|
+
```sh
|
|
434
|
+
rg -nw InputSchema app lib
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**Fix.**
|
|
438
|
+
```ruby
|
|
439
|
+
# before
|
|
440
|
+
class Import < ApplicationAction
|
|
441
|
+
InputSchema = Struct.new(:rows)
|
|
442
|
+
end
|
|
443
|
+
# after
|
|
444
|
+
class Import < ApplicationAction
|
|
445
|
+
RowSet = Struct.new(:rows)
|
|
446
|
+
end
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**Why.** Generated RBS declares a typed `InputSchema` class; Ruby's anonymous
|
|
450
|
+
runtime schema has no name to clash with.
|
|
451
|
+
|
|
452
|
+
### Generated types are stricter
|
|
453
|
+
<!-- upgrading: rbs-stricter-types -->
|
|
454
|
+
|
|
455
|
+
**Symptom.** Steep reports new errors in code that calls your actions. Typed
|
|
456
|
+
`.run` keywords (optional ones accept `nil`; a defaulted required field is an
|
|
457
|
+
optional keyword), `def input: () -> InputSchema`, writers typed
|
|
458
|
+
`(T? value) -> T?`, nullable scalar array elements (`::Array[::String?]`), and
|
|
459
|
+
type arguments on bare generics replace `untyped`. A field typed by an
|
|
460
|
+
anonymous nested struct class now raises `ArgumentError` at generation.
|
|
461
|
+
|
|
462
|
+
**Find.** No pattern: Steep (or `rbs validate`) is the finder.
|
|
463
|
+
|
|
464
|
+
**Fix.** Fix the call sites Steep reports, or give the field an `rbs_type:`
|
|
465
|
+
override (honoured since 1.6). Name anonymous nested struct classes:
|
|
466
|
+
```ruby
|
|
467
|
+
# before
|
|
468
|
+
required :address, Class.new(FieldStruct::Base) { required :zip, :string }
|
|
469
|
+
# after
|
|
470
|
+
Address = Class.new(FieldStruct::Base) { required :zip, :string }
|
|
471
|
+
required :address, Address
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Why.** The types now describe what `run` really accepts and returns.
|
|
475
|
+
|
|
476
|
+
## 1.6.0 → 1.7.0
|
|
477
|
+
|
|
478
|
+
### Redeclaring a step or hook overrides it (last wins)
|
|
479
|
+
<!-- upgrading: step-redeclaration -->
|
|
480
|
+
|
|
481
|
+
**Symptom.** An action (or subclass) that declares the same `step :x`,
|
|
482
|
+
`case_step :x`, `step Child` or hook twice with different options now runs the
|
|
483
|
+
**last** options. Before, the second declaration was silently dropped. A
|
|
484
|
+
subclass's override now takes effect, so behaviour you thought was overridden
|
|
485
|
+
(and wasn't) changes.
|
|
486
|
+
|
|
487
|
+
**Find.** Same-file duplicates (two classes in one file sharing a step name
|
|
488
|
+
show up too):
|
|
489
|
+
```sh
|
|
490
|
+
rg -no --no-heading '^\s*(step|case_step|on_success|on_failure|on_skip|always)\s+:?[A-Za-z_][\w:]*' app lib | sed -E 's/^([^:]+):[0-9]+:[[:space:]]*/\1 /' | sort | uniq -d
|
|
491
|
+
```
|
|
492
|
+
A subclass redeclaring a parent's step lives in another file: no reliable
|
|
493
|
+
pattern. Grep the step names of each base class you subclass.
|
|
494
|
+
|
|
495
|
+
**Fix.** Keep one declaration with the options you mean:
|
|
496
|
+
|
|
497
|
+
<!-- doctest -->
|
|
498
|
+
```ruby
|
|
499
|
+
class Ship < Actionable::Action
|
|
500
|
+
output { optional :packed, :boolean }
|
|
501
|
+
step :pack, if: :ready?
|
|
502
|
+
def pack = @packed = true
|
|
503
|
+
def ready? = true
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
class ShipFragile < Ship
|
|
507
|
+
step :pack, unless: :fragile? # replaces the parent's options, keeps its position
|
|
508
|
+
def fragile? = true
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
Ship.run.packed # => true
|
|
512
|
+
ShipFragile.run.packed # => nil
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
**Why.** Invariant 5 promises a subclass can override inherited steps.
|
|
516
|
+
|
|
517
|
+
### `always` hooks run even after an outcome hook halts
|
|
518
|
+
<!-- upgrading: always-after-halt -->
|
|
519
|
+
|
|
520
|
+
**Symptom.** An `always` hook that never ran after a `fail!` in `on_success`
|
|
521
|
+
now runs, and sees the adopted `Failure`. Under `transactional` it runs inside
|
|
522
|
+
the transaction, so its writes roll back with the failed run. Specs that
|
|
523
|
+
counted `always` calls, or expected its side effect to be absent, fail.
|
|
524
|
+
|
|
525
|
+
**Find.**
|
|
526
|
+
```sh
|
|
527
|
+
rg -n '^\s*always\b' app lib
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
**Fix.** Make each `always` hook correct for every outcome; check `result` when
|
|
531
|
+
the outcome matters:
|
|
532
|
+
|
|
533
|
+
<!-- doctest -->
|
|
534
|
+
```ruby
|
|
535
|
+
class Sync < Actionable::Action
|
|
536
|
+
output { optional :audited, :string }
|
|
537
|
+
step :work
|
|
538
|
+
on_success :publish
|
|
539
|
+
always :audit
|
|
540
|
+
def work = nil
|
|
541
|
+
def publish = fail!(:publish_failed)
|
|
542
|
+
def audit = @audited = result.failure? ? 'failed' : 'ok'
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
result = Sync.run
|
|
546
|
+
result.code # => :publish_failed
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
**Why.** One hook's halt silently skipped cleanup and audit hooks.
|
|
550
|
+
|
|
551
|
+
### Adapter signatures now ship in `sig/`
|
|
552
|
+
<!-- upgrading: adapter-rbs -->
|
|
553
|
+
|
|
554
|
+
**Symptom.** `rbs validate`:
|
|
555
|
+
`::Actionable::Action.transactional has duplicated definitions in sig/mine.rbs:3:4...3:52 (RBS::DuplicatedMethodDefinitionError)`
|
|
556
|
+
when your own sig files declare the Rails, RSpec or Job surface.
|
|
557
|
+
|
|
558
|
+
**Find.**
|
|
559
|
+
```sh
|
|
560
|
+
rg -nP 'def (?:self\.)?(?:transactional|transaction_config|after_commit|after_commit_hooks|perform_actionable|(?:allow|stub)_actionable_\w+|actionable_\w+_result)\b' sig
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**Fix.** Delete your declarations of those methods; the gem's are generated from
|
|
564
|
+
its YARD. They are declared on `Actionable::Action` even when
|
|
565
|
+
`actionable/rails` isn't required (RBS can't make a signature conditional).
|
|
566
|
+
|
|
567
|
+
**Why.** The adapters' public surface is now typed by the gem itself.
|
|
568
|
+
|
|
569
|
+
### `describe` lists `after_commit` hooks under the Rails adapter
|
|
570
|
+
<!-- upgrading: describe-after-commit -->
|
|
571
|
+
|
|
572
|
+
**Symptom.** With `actionable/rails` loaded, `describe[:hooks]` gains an
|
|
573
|
+
`after_commit:` key (an empty Array when there are none), so a spec comparing
|
|
574
|
+
the whole hash with `eq` fails.
|
|
575
|
+
|
|
576
|
+
**Find.**
|
|
577
|
+
```sh
|
|
578
|
+
rg -nP '(?<!RSpec)\.describe\b(?!_text)' spec
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
**Fix.**
|
|
582
|
+
```ruby
|
|
583
|
+
# before
|
|
584
|
+
expect(Pay.describe[:hooks]).to eq(on_success: [:notify], always: [])
|
|
585
|
+
# after
|
|
586
|
+
expect(Pay.describe[:hooks]).to include(on_success: [:notify], always: [])
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
**Why.** `describe` is the contract summary; `after_commit` hooks are part of it.
|
|
590
|
+
|
|
591
|
+
## 1.7.0 → 1.8.0
|
|
592
|
+
|
|
593
|
+
Both checks below raise on code that used to run. `Actionable.verb_position_check`
|
|
594
|
+
switches off the first; nothing switches off the second.
|
|
595
|
+
|
|
596
|
+
### A plain verb must end its step or hook method
|
|
597
|
+
<!-- upgrading: verb-position-guard -->
|
|
598
|
+
|
|
599
|
+
**Symptom.** An action's first run raises:
|
|
600
|
+
```
|
|
601
|
+
Actionable::DefinitionError: Charge#check (app/actions/charge.rb:5) calls `fail` with more code after it, which still runs: use fail! to stop here, or make it the method's last expression
|
|
602
|
+
(see USAGE, Control flow)
|
|
603
|
+
```
|
|
604
|
+
It covers `fail`, `fail_with`, `skip` and `succeed` (and `self.fail` …) in every
|
|
605
|
+
step, case-step branch target and lifecycle hook method (`after_commit`
|
|
606
|
+
included). It is checked once per class, on the first run.
|
|
607
|
+
|
|
608
|
+
**Find.** The cop is the finder; it applies the same rules. Add it to
|
|
609
|
+
`.rubocop.yml` (RuboCop >= 1.72; `require: [actionable/rubocop]` on older):
|
|
610
|
+
```yaml
|
|
611
|
+
plugins:
|
|
612
|
+
- actionable: # `actionable-rb:` from 1.9.0, see 1.8.2 → 1.9.0
|
|
613
|
+
require_path: actionable/rubocop
|
|
614
|
+
|
|
615
|
+
Actionable/VerbNotLast:
|
|
616
|
+
ActionBaseClasses: # replaces the default, so keep the two defaults
|
|
617
|
+
- Actionable::Action
|
|
618
|
+
- ApplicationAction
|
|
619
|
+
- Billing::BaseAction # your own bases defined in other files
|
|
620
|
+
```
|
|
621
|
+
then `bundle exec rubocop --only Actionable/VerbNotLast`. The cop sees one file
|
|
622
|
+
at a time. It follows a subclass of a listed base only when both are in the
|
|
623
|
+
same file, so **list every intermediate base defined in its own file**, by full
|
|
624
|
+
name: with `ResponseReadyAction < ApplicationAction` and
|
|
625
|
+
`Orders::BaseAction < ResponseReadyAction` in separate files, list both. To
|
|
626
|
+
find candidates, count superclass names; the ones with many children are your
|
|
627
|
+
bases:
|
|
628
|
+
```sh
|
|
629
|
+
rg -oNP --no-filename --type ruby '^\s*class\s+[\w:]+\s*<\s*\K[\w:]+' app lib | sort | uniq -c | sort -rn | head -20
|
|
630
|
+
```
|
|
631
|
+
Steps a parent class declares are left to the runtime check.
|
|
632
|
+
Running the suite with the check on finds the rest. For a quick look before
|
|
633
|
+
enabling the cop, these list the two commonest shapes (not definitive):
|
|
634
|
+
```sh
|
|
635
|
+
rg -nP '^\s*(?:self\.)?(?:fail|fail_with|skip|succeed)\b(?!!).*\b(?:if|unless)\b' app lib
|
|
636
|
+
rg -nP '\b(?:fail|fail_with|skip|succeed)\b(?!!)[^#\n]*\b(?:and|or)\s+(?:return|next|break)\b' app lib
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
**Fix.** If the code after the verb should not run, add the bang; if it
|
|
640
|
+
should, make the verb last (or `return` it):
|
|
641
|
+
```ruby
|
|
642
|
+
# before
|
|
643
|
+
def check
|
|
644
|
+
fail :card_expired if card.expired?
|
|
645
|
+
charge_card
|
|
646
|
+
end
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
<!-- doctest -->
|
|
650
|
+
```ruby
|
|
651
|
+
class Charge < Actionable::Action
|
|
652
|
+
step :check
|
|
653
|
+
step :charge_card
|
|
654
|
+
def check
|
|
655
|
+
fail!(:card_expired) if expired? # stops here
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def charge_card = nil
|
|
659
|
+
def expired? = true
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
Charge.run.code # => :card_expired
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
`fail(:x) and return` never returns (`fail` returns `false`): write
|
|
666
|
+
`return fail(:x)`. [USAGE, Where a plain verb may go](USAGE.md#where-a-plain-verb-may-go)
|
|
667
|
+
has the full table. If you can't fix every offense before deploying, set
|
|
668
|
+
`Actionable.verb_position_check = false` in an initializer and keep the cop in
|
|
669
|
+
CI until you can turn it back on. The check is skipped on Ruby 3.2 without the
|
|
670
|
+
`prism` gem, and for methods from `eval` / `define_method`; the cop still
|
|
671
|
+
covers those.
|
|
672
|
+
|
|
673
|
+
**Why.** `fail :x if bad?` followed by more code keeps running: almost always
|
|
674
|
+
a `fail!` missing its bang.
|
|
675
|
+
|
|
676
|
+
### `fail` / `skip` codes must be identifiers
|
|
677
|
+
<!-- upgrading: identifier-codes -->
|
|
678
|
+
|
|
679
|
+
**Symptom.** At the call:
|
|
680
|
+
```
|
|
681
|
+
ArgumentError: Codes: failure code "card expired" is not an identifier; use a Symbol like :card_expired. Inside an action, fail records a failure, it is not Kernel#fail: to raise an exception, use raise
|
|
682
|
+
```
|
|
683
|
+
(`skip code ...` for a skip). A code must be a Symbol or String matching
|
|
684
|
+
`/\A[[:alpha:]_][[:alnum:]_]*[?!]?\z/`. Sentences, dotted (`'payment.declined'`)
|
|
685
|
+
and dashed (`:"not-found"`) codes, codes starting with a digit (`'404'`) and
|
|
686
|
+
classes (`fail GatewayError, 'down'`) all raise. Applies to `fail`, `fail!`,
|
|
687
|
+
`fail_with`, `fail_with!` (its `code:`), `skip` and `skip!`. **No opt-out:**
|
|
688
|
+
`verb_position_check = false` doesn't cover it.
|
|
689
|
+
|
|
690
|
+
**Find.** Three patterns: quoted codes, `code:` keywords, and class codes. They
|
|
691
|
+
skip calls on a receiver (`run.fail!(…)` on a model with its own `fail!`) and
|
|
692
|
+
also `self.fail …`, which is rare. A bare `fail` outside an action class
|
|
693
|
+
(`Kernel#fail` in a model or job) is fine to leave:
|
|
694
|
+
```sh
|
|
695
|
+
rg -nP --type ruby "(?<![\w.])(?:fail|skip)!?[ (]\s*(?:'[^']*[^\w'?!][^']*'|\"[^\"]*[^\w\"?!][^\"]*\"|['\"]\d|:['\"])" app lib
|
|
696
|
+
rg -nP --type ruby "\bcode:\s*(?:'[^']*[^\w'?!][^']*'|\"[^\"]*[^\w\"?!][^\"]*\"|['\"]\d|:['\"])" app lib
|
|
697
|
+
rg -nP --type ruby '(?<![\w.])fail!?[ (]\s*[A-Z]\w*(?:::[A-Z]\w*)*\s*(?:,|\)|$)' app lib
|
|
698
|
+
```
|
|
699
|
+
A code built at run time (`fail!(code_for(response))`) can't be found
|
|
700
|
+
statically; the runtime check is the finder.
|
|
701
|
+
|
|
702
|
+
**Fix.** Use a Symbol code and pass the sentence as the message; `raise` when
|
|
703
|
+
you meant an exception:
|
|
704
|
+
```ruby
|
|
705
|
+
# before
|
|
706
|
+
fail('card expired')
|
|
707
|
+
fail(GatewayError, 'gateway down')
|
|
708
|
+
skip('payment.declined')
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
<!-- doctest -->
|
|
712
|
+
```ruby
|
|
713
|
+
class Pay < Actionable::Action
|
|
714
|
+
step :charge
|
|
715
|
+
def charge = fail!(:card_expired, 'card expired') # was: fail('card expired')
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
result = Pay.run
|
|
719
|
+
result.code # => :card_expired
|
|
720
|
+
result.message # => "card expired"
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
**Why.** `fail 'card expired'` is `Kernel#fail` habit; inside an action it
|
|
724
|
+
recorded the sentence as a code and kept running.
|
|
725
|
+
|
|
726
|
+
## 1.8.0 → 1.8.1
|
|
727
|
+
|
|
728
|
+
No caller action. The `Actionable/VerbNotLast` cop now resolves a superclass by
|
|
729
|
+
its lexical full name, as Ruby does, so two same-named classes in one file no
|
|
730
|
+
longer confuse it: it can only report fewer false offenses, never new ones on
|
|
731
|
+
code it already accepted. Everything else in 1.8.1 is test and tooling.
|
|
732
|
+
|
|
733
|
+
## 1.8.1 → 1.8.2
|
|
734
|
+
|
|
735
|
+
A patch release, but three fixes change what an app can see. Each is a bug
|
|
736
|
+
fix: the old behaviour contradicted the documentation.
|
|
737
|
+
|
|
738
|
+
| Change | Who is affected | What you see | Why |
|
|
739
|
+
|---|---|---|---|
|
|
740
|
+
| [`case_step` branch guards are honoured](#case_step-branch-guards-are-honoured) | Actions with `on …, if:` / `unless:` inside a `case_step` | **Silent:** a branch whose guard says no no longer runs; the run still succeeds | The guard was accepted and silently ignored |
|
|
741
|
+
| [`Actionable::RBS` refuses colliding codes](#actionablerbs-refuses-codes-that-share-an-output-class-name) | `typed_outcomes` actions with codes like `:ready` + `:ready_`, or non-ASCII codes | `ArgumentError` from `generate`, naming both codes | Both schemas landed in one RBS class, so one was typed wrong |
|
|
742
|
+
| [The verb check reads an aliased step's `def`](#the-verb-check-reads-an-aliased-steps-original-def) | Steps that name an `alias_method` alias of a method with a misplaced `fail` / `skip` | `DefinitionError` on first run | Aliased steps skipped the check that 1.8.0 applies to every other step |
|
|
743
|
+
|
|
744
|
+
**From 1.8.1:**
|
|
745
|
+
1. Bump the tag to `v1.8.2`.
|
|
746
|
+
2. Run the `case_step` Find below, and check that each guarded branch should
|
|
747
|
+
really be skipped when its guard says no.
|
|
748
|
+
3. If you use `typed_outcomes`, regenerate your RBS: `generate` names any clash.
|
|
749
|
+
4. Run the suite.
|
|
750
|
+
Nothing else changed for callers. Codes ending in `?` / `!` get valid RBS class
|
|
751
|
+
names (`…PredicateFailureOutput`); before, their RBS didn't parse.
|
|
752
|
+
|
|
753
|
+
### `case_step` branch guards are honoured
|
|
754
|
+
<!-- upgrading: case-branch-guards -->
|
|
755
|
+
|
|
756
|
+
**Symptom.** A `case_step` branch declared with `if:` / `unless:` no longer
|
|
757
|
+
runs when its guard opts out: an output field it set is `nil`, a side effect
|
|
758
|
+
doesn't happen, or a spec that expected the branch to run fails. The run still
|
|
759
|
+
succeeds. A guarded-out branch does **not** fall through to the next `on` or
|
|
760
|
+
to the `default`.
|
|
761
|
+
|
|
762
|
+
**Find.** Guarded branches inside a `case_step` block:
|
|
763
|
+
```sh
|
|
764
|
+
rg -n '^\s*(on|default)\b.*,\s*(if|unless):' app lib
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
**Fix.** If the branch should run regardless, drop the guard. If a
|
|
768
|
+
guarded-out branch should fall back to another target, say so in a guard-free
|
|
769
|
+
branch body:
|
|
770
|
+
```ruby
|
|
771
|
+
# before: the guard was ignored, so 'sms' always sent an SMS
|
|
772
|
+
case_step :kind do
|
|
773
|
+
on 'sms', :send_sms, if: :sms_enabled?
|
|
774
|
+
default :send_email
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# after: 'sms' with SMS off sends an email
|
|
778
|
+
case_step :kind do
|
|
779
|
+
on 'sms', :route_sms
|
|
780
|
+
default :send_email
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def route_sms = sms_enabled? ? send_sms : send_email
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
**Why.** The guard was accepted and silently dropped; a branch now behaves
|
|
787
|
+
like the plain step it is.
|
|
788
|
+
|
|
789
|
+
### `Actionable::RBS` refuses codes that share an output class name
|
|
790
|
+
<!-- upgrading: rbs-outcome-class-names -->
|
|
791
|
+
|
|
792
|
+
**Symptom.** Under `typed_outcomes`, `Actionable::RBS.generate` raises:
|
|
793
|
+
```
|
|
794
|
+
ArgumentError: Billing::Charge: skip codes :ready and :ready_ both name ReadySkipOutput in the generated RBS; rename one of the codes
|
|
795
|
+
```
|
|
796
|
+
(or `... is not a valid RBS class name ...` for a code whose class name would not
|
|
797
|
+
be plain ASCII starting with a capital, e.g. `:日本` or `:foo_é`). Codes ending in `?` / `!` that declare an output
|
|
798
|
+
schema now generate `…PredicateFailureOutput` / `…BangSkipOutput`; before,
|
|
799
|
+
their RBS didn't parse, so nothing can reference the old names.
|
|
800
|
+
|
|
801
|
+
**Find.** Run `Actionable::RBS.generate` (your sigs task) for every action; it
|
|
802
|
+
names each clash. No search finds them reliably.
|
|
803
|
+
|
|
804
|
+
**Fix.** Rename one of the two codes, and its `fail` / `skip` calls:
|
|
805
|
+
```ruby
|
|
806
|
+
# before
|
|
807
|
+
skips(:ready) { required :id, :integer }
|
|
808
|
+
skips(:ready_) { required :id, :integer }
|
|
809
|
+
|
|
810
|
+
# after
|
|
811
|
+
skips(:ready) { required :id, :integer }
|
|
812
|
+
skips(:ready_later) { required :id, :integer }
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
**Why.** Both schemas went into one RBS class, so one of them typed wrong.
|
|
816
|
+
|
|
817
|
+
### The verb check reads an aliased step's original `def`
|
|
818
|
+
<!-- upgrading: verb-check-alias -->
|
|
819
|
+
|
|
820
|
+
**Symptom.** On first run, the same `DefinitionError` as for any
|
|
821
|
+
[verb-position offense](#a-plain-verb-must-end-its-step-or-hook-method),
|
|
822
|
+
naming the step (`Charge#charge`) and the `file:line` of the aliased `def`:
|
|
823
|
+
```
|
|
824
|
+
Actionable::DefinitionError: Charge#charge (app/actions/charge.rb:4) calls `fail` with more code after it, which still runs: use fail! to stop here, or make it the method's last expression
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
**Find.** Aliases in action classes; read the `def` each step alias points
|
|
828
|
+
at. The `Actionable/VerbNotLast` cop doesn't follow aliases, so it won't list
|
|
829
|
+
these:
|
|
830
|
+
```sh
|
|
831
|
+
rg -n '^\s*(alias_method\b|alias\s)' app lib
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
**Fix.** As for any offense: a bang verb, or make the verb the last
|
|
835
|
+
expression:
|
|
836
|
+
```ruby
|
|
837
|
+
# before
|
|
838
|
+
step :charge
|
|
839
|
+
def do_charge
|
|
840
|
+
fail :late if late?
|
|
841
|
+
@charged = true
|
|
842
|
+
end
|
|
843
|
+
alias_method :charge, :do_charge
|
|
844
|
+
|
|
845
|
+
# after
|
|
846
|
+
step :charge
|
|
847
|
+
def do_charge
|
|
848
|
+
fail! :late if late?
|
|
849
|
+
@charged = true
|
|
850
|
+
end
|
|
851
|
+
alias_method :charge, :do_charge
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
**Why.** The check looked the `def` up by the alias's name, found none, and
|
|
855
|
+
skipped the step.
|
|
856
|
+
|
|
857
|
+
## 1.8.2 → 1.9.0
|
|
858
|
+
|
|
859
|
+
The gem is renamed. Nothing in the library changed.
|
|
860
|
+
|
|
861
|
+
| Change | Who is affected | What you see | Why |
|
|
862
|
+
|---|---|---|---|
|
|
863
|
+
| [The gem is named `actionable-rb`](#the-gem-is-named-actionable-rb) | Every app (the Gemfile line), and apps that load the cop with `plugins:` | `bundle install` fails: "Could not find gem 'actionable' in …"; RuboCop: "Failed to load plugin `actionable`" | The `actionable` name on rubygems.org belongs to an unrelated 2014 gem |
|
|
864
|
+
|
|
865
|
+
**From 1.8.2:**
|
|
866
|
+
1. In the Gemfile, change `gem 'actionable'` to `gem 'actionable-rb'` and the
|
|
867
|
+
tag to `v1.9.0`, then `bundle install`.
|
|
868
|
+
2. If `.rubocop.yml` loads the cop with `plugins:`, rename its key to
|
|
869
|
+
`actionable-rb`.
|
|
870
|
+
3. Run the suite. No code changes: `require 'actionable'` and every constant
|
|
871
|
+
stay the same.
|
|
872
|
+
|
|
873
|
+
### The gem is named `actionable-rb`
|
|
874
|
+
<!-- upgrading: gem-renamed-actionable-rb -->
|
|
875
|
+
|
|
876
|
+
**Symptom.** `bundle install` (or `bundle update actionable`) stops with
|
|
877
|
+
`Could not find gem 'actionable' in https://github.com/Paymentbox-com/actionable`
|
|
878
|
+
(Bundler matches a git source by the gemspec's name). With the cop loaded by
|
|
879
|
+
`plugins:`, `rubocop` stops with ``Failed to load plugin `actionable` because the
|
|
880
|
+
corresponding plugin class could not be determined``.
|
|
881
|
+
|
|
882
|
+
**Find.**
|
|
883
|
+
```sh
|
|
884
|
+
rg -n "gem ['\"]actionable['\"]" Gemfile gemfiles
|
|
885
|
+
rg -n -A1 '^\s*-\s*actionable:\s*$' .rubocop.yml
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
**Fix.**
|
|
889
|
+
```ruby
|
|
890
|
+
# Gemfile, before
|
|
891
|
+
gem 'actionable', git: 'https://github.com/Paymentbox-com/actionable', tag: 'v1.8.2'
|
|
892
|
+
# after
|
|
893
|
+
gem 'actionable-rb', git: 'https://github.com/Paymentbox-com/actionable', tag: 'v1.9.0'
|
|
894
|
+
```
|
|
895
|
+
```yaml
|
|
896
|
+
# .rubocop.yml, before
|
|
897
|
+
plugins:
|
|
898
|
+
- actionable:
|
|
899
|
+
require_path: actionable/rubocop
|
|
900
|
+
# after
|
|
901
|
+
plugins:
|
|
902
|
+
- actionable-rb:
|
|
903
|
+
require_path: actionable/rubocop
|
|
904
|
+
```
|
|
905
|
+
Keep `require 'actionable'` wherever you have it. A Gemfile that relies on
|
|
906
|
+
`Bundler.require` needs no `require:` option: the gem ships an
|
|
907
|
+
`actionable-rb.rb` that loads the library. The older `require:
|
|
908
|
+
[actionable/rubocop]` form in `.rubocop.yml` keeps working unchanged.
|
|
909
|
+
|
|
910
|
+
**Why.** To publish on rubygems.org, the gem needs a name nobody else holds;
|
|
911
|
+
`actionable` there is an unrelated project from 2014.
|
|
912
|
+
|
|
913
|
+
## Class-level API an app base class may rely on
|
|
914
|
+
|
|
915
|
+
Unchanged from 1.5.1 to 1.8.1, so a shared `ApplicationAction` that overrides
|
|
916
|
+
or calls these needs no edit: `fails` / `skips` (the declarations),
|
|
917
|
+
`declared_failures` / `declared_skips`, `allows_failure_code?` /
|
|
918
|
+
`allows_skip_code?`, `failure_output_schema` / `skip_output_schema`, `halt!`,
|
|
919
|
+
`fail_with!`, and the `Failure` / `Skipped` / `Success` constructors. `Result`
|
|
920
|
+
only gained an `@api private` accessor. Anything marked `@api private` in the
|
|
921
|
+
YARD docs can change in any release.
|
|
922
|
+
|
|
923
|
+
## New, opt-in: worth adopting
|
|
924
|
+
|
|
925
|
+
| Since | Feature | One line | Reference |
|
|
926
|
+
|---|---|---|---|
|
|
927
|
+
| 1.5.2 | Savepoint control | `transactional model: :x, requires_new: false` joins an outer transaction instead of taking a savepoint. | [USAGE, Rails adapter](USAGE.md#rails-adapter) |
|
|
928
|
+
| 1.6.0 | Typed `Actionable::RBS` | Typed `.run` keywords, `InputSchema`, output ivars and the real superclass chain for each action. | [USAGE, RBS for your actions](USAGE.md#rbs-for-your-actions) |
|
|
929
|
+
| 1.7.0 | `honor_retry_metadata` | Declared `retry: true` / `retry: false` decides the job's disposition (ActiveJob: `retry_on Actionable::Job::RetryableFailure`). | [USAGE, Background jobs](USAGE.md#background-jobs) |
|
|
930
|
+
| 1.7.0 | `after_commit` | Emails, webhooks and enqueues run only after the outermost transaction commits (ActiveRecord >= 7.2). | [USAGE, Rails adapter](USAGE.md#rails-adapter) |
|
|
931
|
+
| 1.7.0 | RSpec matchers, typed | `perform_actionable` and the `allow_/stub_actionable_*` helpers now have RBS. | [USAGE, RSpec integration](USAGE.md#rspec-integration) |
|
|
932
|
+
| 1.8.0 | `Actionable/VerbNotLast` cop | Lints misplaced plain verbs in CI, before the first run. | [USAGE, Where a plain verb may go](USAGE.md#where-a-plain-verb-may-go) |
|
|
933
|
+
|
|
934
|
+
## If something else breaks
|
|
935
|
+
|
|
936
|
+
**Bisect by version.** Pin each release in turn and run the failing spec; the
|
|
937
|
+
first tag that fails names the section above to reread:
|
|
938
|
+
```ruby
|
|
939
|
+
gem 'actionable', git: 'https://github.com/Paymentbox-com/actionable', tag: 'v1.5.2' # then v1.6.0, v1.7.0, v1.8.0
|
|
940
|
+
```
|
|
941
|
+
From `v1.9.0` the gem is `actionable-rb`, so that line says `gem 'actionable-rb'`.
|
|
942
|
+
Remember the field_struct floor at each step (0.11 for 1.5.2, 0.12 from 1.6.0).
|
|
943
|
+
|
|
944
|
+
**A guard offense or a real bug?**
|
|
945
|
+
|
|
946
|
+
- `DefinitionError` "... calls `fail` with more code after it ..." is the verb
|
|
947
|
+
guard. It goes away with `Actionable.verb_position_check = false`; a real bug
|
|
948
|
+
doesn't. The cop reports the same line.
|
|
949
|
+
- `ArgumentError` "... code ... is not an identifier" is the code check, raised
|
|
950
|
+
at the `fail` / `skip` call itself. The backtrace's first app frame is the
|
|
951
|
+
line to change.
|
|
952
|
+
- `DefinitionError` "... declares step(s) calling ... but does not implement
|
|
953
|
+
them" or "... set @result ..." are the 1.5.2 definition checks.
|
|
954
|
+
- Anything else (a different result code, missing output, a hook that runs or
|
|
955
|
+
doesn't) is behaviour: find the version with the bisect above, then the entry
|
|
956
|
+
in that version's section. If none fits, it's a bug. Open an issue with the
|
|
957
|
+
action and the two versions.
|