automatic 14.12.2 → 26.08

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 (172) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +635 -83
  3. data/VERSION +1 -1
  4. data/automatic.gemspec +109 -248
  5. data/bin/automatic +20 -139
  6. data/config/feed2console.yml +10 -4
  7. data/config/feed2markdown.yml +41 -0
  8. data/doc/AI_TUTORIAL.md +518 -0
  9. data/doc/BASIC_DESIGN.md +516 -0
  10. data/doc/COPYING.LESSER +165 -0
  11. data/doc/DEPLOYMENT.md +824 -0
  12. data/doc/LICENSE.md +14 -0
  13. data/doc/PLUGINS.md +1875 -0
  14. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  15. data/doc/POLICY.md +857 -0
  16. data/doc/QUICKSTART.md +256 -0
  17. data/doc/RELEASING.md +381 -0
  18. data/doc/REQUIREMENTS.md +526 -0
  19. data/doc/VERSIONS +208 -0
  20. data/lib/automatic/cli.rb +248 -0
  21. data/lib/automatic/environment.rb +31 -5
  22. data/lib/automatic/feed_maker.rb +10 -9
  23. data/lib/automatic/feed_parser.rb +51 -35
  24. data/lib/automatic/http.rb +107 -0
  25. data/lib/automatic/log.rb +49 -18
  26. data/lib/automatic/opml.rb +3 -1
  27. data/lib/automatic/pipeline.rb +63 -32
  28. data/lib/automatic/recipe.rb +56 -17
  29. data/lib/automatic/version.rb +14 -1
  30. data/lib/automatic.rb +78 -20
  31. data/plugins/custom_feed/svn_log.rb +73 -32
  32. data/plugins/custom_feed/web.rb +348 -0
  33. data/plugins/filter/absolute_uri.rb +43 -27
  34. data/plugins/filter/accept.rb +38 -45
  35. data/plugins/filter/claude.rb +217 -0
  36. data/plugins/filter/clear.rb +12 -8
  37. data/plugins/filter/description_link.rb +49 -51
  38. data/plugins/filter/full_feed.rb +158 -52
  39. data/plugins/filter/gemini.rb +216 -0
  40. data/plugins/filter/github_feed.rb +38 -26
  41. data/plugins/filter/ignore.rb +33 -44
  42. data/plugins/filter/image.rb +36 -25
  43. data/plugins/filter/image_source.rb +58 -52
  44. data/plugins/filter/join.rb +107 -0
  45. data/plugins/filter/one.rb +19 -26
  46. data/plugins/filter/open_ai.rb +198 -0
  47. data/plugins/filter/rand.rb +16 -17
  48. data/plugins/filter/sakura_ai.rb +205 -0
  49. data/plugins/filter/sanitize.rb +29 -34
  50. data/plugins/filter/sort.rb +20 -27
  51. data/plugins/filter/tumblr_resize.rb +31 -23
  52. data/plugins/notify/ikachan.rb +86 -48
  53. data/plugins/provide/fluentd.rb +43 -24
  54. data/plugins/publish/amazon_s3.rb +73 -40
  55. data/plugins/publish/console.rb +19 -16
  56. data/plugins/publish/console_link.rb +20 -17
  57. data/plugins/publish/eject.rb +48 -26
  58. data/plugins/publish/fluentd.rb +50 -30
  59. data/plugins/publish/hatena_bookmark.rb +88 -71
  60. data/plugins/publish/instapaper.rb +69 -59
  61. data/plugins/publish/markdown.rb +278 -0
  62. data/plugins/publish/memcached.rb +35 -29
  63. data/plugins/store/database.rb +50 -48
  64. data/plugins/store/digest.rb +212 -0
  65. data/plugins/store/file.rb +99 -68
  66. data/plugins/store/full_text.rb +32 -25
  67. data/plugins/store/permalink.rb +18 -22
  68. data/plugins/subscription/feed.rb +34 -21
  69. data/plugins/subscription/link.rb +31 -32
  70. data/plugins/subscription/text.rb +32 -46
  71. data/plugins/subscription/tumblr.rb +55 -44
  72. data/plugins/subscription/xml.rb +40 -36
  73. metadata +108 -321
  74. data/Gemfile +0 -38
  75. data/Rakefile +0 -59
  76. data/doc/ChangeLog +0 -303
  77. data/doc/PLUGINS +0 -750
  78. data/doc/PLUGINS.ja +0 -753
  79. data/doc/README +0 -511
  80. data/doc/README.ja +0 -519
  81. data/plugins/filter/google_news.rb +0 -50
  82. data/plugins/publish/google_calendar.rb +0 -84
  83. data/plugins/publish/hipchat.rb +0 -46
  84. data/plugins/publish/pocket.rb +0 -45
  85. data/plugins/publish/twitter.rb +0 -58
  86. data/plugins/subscription/chan_toru.rb +0 -57
  87. data/plugins/subscription/g_guide.rb +0 -57
  88. data/plugins/subscription/pocket.rb +0 -51
  89. data/plugins/subscription/twitter.rb +0 -69
  90. data/plugins/subscription/twitter_search.rb +0 -50
  91. data/plugins/subscription/weather.rb +0 -33
  92. data/script/build +0 -84
  93. data/spec/fixtures/sampleFeeds.tsv +0 -1
  94. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  95. data/spec/fixtures/sampleRecipe.yml +0 -24
  96. data/spec/lib/automatic/log_spec.rb +0 -32
  97. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  98. data/spec/lib/automatic/recipe_spec.rb +0 -40
  99. data/spec/lib/automatic_spec.rb +0 -99
  100. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  101. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  102. data/spec/plugins/filter/accept_spec.rb +0 -331
  103. data/spec/plugins/filter/clear_spec.rb +0 -49
  104. data/spec/plugins/filter/description_link_spec.rb +0 -138
  105. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  106. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  107. data/spec/plugins/filter/google_news_spec.rb +0 -69
  108. data/spec/plugins/filter/ignore_spec.rb +0 -328
  109. data/spec/plugins/filter/image_source_spec.rb +0 -89
  110. data/spec/plugins/filter/image_spec.rb +0 -65
  111. data/spec/plugins/filter/one_spec.rb +0 -71
  112. data/spec/plugins/filter/rand_spec.rb +0 -52
  113. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  114. data/spec/plugins/filter/sort_spec.rb +0 -189
  115. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  116. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  117. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  118. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  119. data/spec/plugins/publish/console_spec.rb +0 -30
  120. data/spec/plugins/publish/eject_spec.rb +0 -40
  121. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  122. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  123. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  124. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  125. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  126. data/spec/plugins/publish/memcached_spec.rb +0 -63
  127. data/spec/plugins/publish/pocket_spec.rb +0 -51
  128. data/spec/plugins/publish/twitter_spec.rb +0 -73
  129. data/spec/plugins/store/file_spec.rb +0 -58
  130. data/spec/plugins/store/full_text_spec.rb +0 -152
  131. data/spec/plugins/store/permalink_spec.rb +0 -206
  132. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  133. data/spec/plugins/subscription/feed_spec.rb +0 -71
  134. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  135. data/spec/plugins/subscription/link_spec.rb +0 -72
  136. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  137. data/spec/plugins/subscription/text_spec.rb +0 -84
  138. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  139. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  140. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  141. data/spec/plugins/subscription/weather_spec.rb +0 -44
  142. data/spec/plugins/subscription/xml_spec.rb +0 -84
  143. data/spec/spec_helper.rb +0 -106
  144. data/spec/user_dir/plugins/store/mock.rb +0 -16
  145. data/test/fixtures/sampleOPML.xml +0 -11
  146. data/test/integration/test_absoluteurl.yml +0 -25
  147. data/test/integration/test_activerecord.yml +0 -24
  148. data/test/integration/test_add_pocket.yml +0 -26
  149. data/test/integration/test_chan_toru.yml +0 -21
  150. data/test/integration/test_descriptionlink.yml +0 -21
  151. data/test/integration/test_fluentd.yml +0 -22
  152. data/test/integration/test_fulltext.yml +0 -30
  153. data/test/integration/test_google_news.yml +0 -21
  154. data/test/integration/test_googlealert.yml +0 -21
  155. data/test/integration/test_hatenabookmark.yml +0 -30
  156. data/test/integration/test_ignore.yml +0 -25
  157. data/test/integration/test_ignore2.yml +0 -22
  158. data/test/integration/test_image2local.yml +0 -33
  159. data/test/integration/test_instapaper.yml +0 -26
  160. data/test/integration/test_link2local.yml +0 -34
  161. data/test/integration/test_one.yml +0 -23
  162. data/test/integration/test_pocket.yml +0 -22
  163. data/test/integration/test_rand.yml +0 -21
  164. data/test/integration/test_sanitize.yml +0 -23
  165. data/test/integration/test_sort.yml +0 -36
  166. data/test/integration/test_svnlog.yml +0 -15
  167. data/test/integration/test_text2feed.yml +0 -36
  168. data/test/integration/test_tumblr2local.yml +0 -43
  169. data/test/integration/test_twitter_search.yml +0 -22
  170. data/test/integration/test_weather.yml +0 -19
  171. data/test/integration/test_xml2fluentd.yml +0 -21
  172. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,526 @@
1
+ # Requirements: a plugin pipeline for unattended processing
2
+
3
+ ## 1. Purpose of this document
4
+
5
+ This document states what Automatic Ruby is for, what it accepts, what it
6
+ produces, and where its responsibility ends. It does not describe how any of it
7
+ is built; that belongs to [`BASIC_DESIGN.md`](BASIC_DESIGN.md), and how a change
8
+ to it is carried out belongs to [`POLICY.md`](POLICY.md). The two public
9
+ interfaces it names — the Recipe and the plugin contract — are specified in
10
+ [`PLUGINS.md`](PLUGINS.md).
11
+
12
+ It stands on its own. Nothing in it is completed by a document kept in another
13
+ repository.
14
+
15
+ Where this document says the software *does* something, that is a statement
16
+ about the present implementation, reconstructed by reading it. Where it says the
17
+ software *shall* do something, that is a requirement a change is held to.
18
+
19
+ ## 2. Name
20
+
21
+ `automaticruby`, published as the RubyGem `automatic` and invoked as the command
22
+ `automatic`. The repository name carries the language because the framework is
23
+ the product: a Ruby program whose behaviour is written in Ruby plugins and
24
+ assembled in a configuration file.
25
+
26
+ ## 3. Purpose
27
+
28
+ A person has a recurring job that consists of acquiring information, processing
29
+ what is useful, optionally persisting a record so it is not handled twice, and
30
+ publishing a portable result. Tomorrow they have another job
31
+ of the same shape with different endpoints. Writing each as a script means
32
+ writing the fetching, the filtering, the de-duplication and the retrying again
33
+ every time.
34
+
35
+ Automatic Ruby exists so that those jobs are *assembled* rather than written. A
36
+ step is a plugin — a small Ruby class with one method. A job is a Recipe — a
37
+ YAML file naming the plugins in order and their settings. Running the job is one
38
+ command, which is safe to put in `cron`.
39
+
40
+ The framework's contribution is exactly three things:
41
+
42
+ - a uniform contract every step obeys, so that steps compose,
43
+ - a value passed from step to step, so that they have something to compose over,
44
+ - a loader that finds a step by name, so that a Recipe can name it.
45
+
46
+ Everything else — what is fetched, what is filtered, what is published — belongs
47
+ to a plugin, and plugins are expected to come and go.
48
+
49
+ ## 4. What it is not
50
+
51
+ - **Not an application.** It has no behaviour of its own. With no Recipe it does
52
+ nothing, and every useful thing it does is a plugin's doing.
53
+ - **Not a daemon or a scheduler.** One invocation runs one Recipe once and
54
+ exits. Repetition is `cron`'s job, and periodic running is deliberately left
55
+ outside; see section 15.
56
+ - **Not a web application.** It serves nothing and listens on nothing.
57
+ - **Not a feed reader.** Feeds are the shape the pipeline value happens to take
58
+ (section 8), not the purpose. A Recipe that never touches a feed is a normal
59
+ use of it.
60
+ - **Not a general workflow engine.** There are no branches, no loops, no
61
+ conditions and no fan-out. A Recipe is a straight line, and it stays one.
62
+ - **Not a hosted or multi-tenant system.** It runs as one person, on one
63
+ machine, against that person's own accounts and files.
64
+ - **Not a sandbox.** A Recipe names Ruby classes and those classes run with the
65
+ full privileges of the invoking user. See section 17.
66
+
67
+ ## 5. Who uses it
68
+
69
+ One person, on their own machine or their own server, running their own Recipes
70
+ against their own accounts. They are able to read and write Ruby, because
71
+ writing a plugin is the intended way to extend the system, and the plugin
72
+ contract is small enough that this is a reasonable expectation rather than a
73
+ burden.
74
+
75
+ There is no notion of a second user, no account, no permission and no
76
+ separation. Two people sharing a Recipe share the credentials in it.
77
+
78
+ ## 6. Where it runs
79
+
80
+ - A Unix-like system: GNU/Linux and macOS are what it is used on.
81
+ - A maintained Ruby (section 20), installed either from RubyGems or from a
82
+ source checkout.
83
+ - Unattended, from `cron`, as often as the operator chooses.
84
+
85
+ Windows is not supported. Nothing is known to be deliberately incompatible with
86
+ it, but no plugin, no path handling and no test is written with it in mind, and
87
+ several plugins shell out to Unix commands.
88
+
89
+ ## 7. The two public interfaces
90
+
91
+ Two things in this repository are interfaces that people outside it depend on,
92
+ and they are treated accordingly.
93
+
94
+ ### 7.1 The Recipe
95
+
96
+ A Recipe is a YAML file listing the plugins of one job, in order, each with its
97
+ settings. Its structure is specified in [`PLUGINS.md`](PLUGINS.md) section 2.
98
+
99
+ A Recipe written for an earlier release shall keep working. Recipes live outside
100
+ this repository, in `~/.automatic/config` and in operators' `cron` entries, and
101
+ this repository cannot see them, cannot migrate them and shall not silently
102
+ change what they mean. Removing a key, renaming a key, changing a default so
103
+ that an unchanged Recipe does something else, and changing how a value is
104
+ interpreted are all breaking changes, are made only deliberately, and are
105
+ recorded in [`VERSIONS`](VERSIONS).
106
+
107
+ Adding an optional key with a default that preserves current behaviour is not a
108
+ breaking change.
109
+
110
+ ### 7.2 The plugin contract
111
+
112
+ The contract a plugin class obeys — where it lives, how it is named, how it is
113
+ constructed, what it receives and what it must return — is specified in
114
+ [`PLUGINS.md`](PLUGINS.md) section 3.
115
+
116
+ It is an interface because plugins exist outside this repository, under
117
+ `~/.automatic/plugins`, and the framework loads them by the same rules it loads
118
+ its own. Changing the contract breaks code this repository has never seen.
119
+
120
+ ## 8. The pipeline value
121
+
122
+ Every plugin receives one value and returns one value. That value is the
123
+ pipeline, and it is:
124
+
125
+ > an `Array` of feed objects, where a feed object responds to `#items`, and each
126
+ > item responds to `#title`, `#link`, `#description`, `#date`, `#author`,
127
+ > `#comments`, `#source`, `#enclosure` and `#content_encoded`.
128
+
129
+ In practice the elements are RSS objects produced by Ruby's `RSS::Maker` or by
130
+ `RSS::Parser`. `Automatic::FeedMaker` builds them from plain values, so a plugin
131
+ that acquires something which is not a feed — a row of a TSV file, an API
132
+ response, a weather report — converts it into this shape and the rest of the
133
+ pipeline is unaffected.
134
+
135
+ This is the framework's one substantive constraint on plugins, and it is what
136
+ makes them compose. Three consequences are requirements:
137
+
138
+ - A plugin shall accept this shape and return this shape. A plugin that returns
139
+ something else ends the pipeline for every plugin after it.
140
+ - `link` may be `nil`. Several filters signal "not applicable" by setting it,
141
+ and a plugin that dereferences it without checking is at fault.
142
+ - The names above are RSS names, and they are used for values that are not RSS.
143
+ `title` may hold a weather condition and `description` may hold an arbitrary
144
+ string. This is a known cost of having one shape, and it is accepted.
145
+
146
+ The value has no schema beyond that, no validation and no version. A plugin that
147
+ needs a field the previous plugin never set gets `nil`.
148
+
149
+ This is an **internal** representation, and it is not a statement about what a
150
+ run produces. A plugin that writes the pipeline out in some other form — a
151
+ Markdown document, a row in a database, a request body — serializes it at the
152
+ moment it leaves the pipeline, and the value the next plugin receives is
153
+ unchanged. An output format is therefore never a second pipeline
154
+ representation, and shall not become one; see section 10.2.
155
+
156
+ ## 9. Input
157
+
158
+ The framework itself reads:
159
+
160
+ - **the Recipe**, named by `-c` (section 11),
161
+ - **the user directory**, `~/.automatic` (section 13),
162
+ - **plugin files**, from the user directory and from the installed package.
163
+
164
+ Everything else is read by a plugin, on its own account. Feeds, web pages, APIs,
165
+ TSV files, XML documents, a Subversion repository and a SQLite database are all
166
+ plugin inputs, not framework inputs.
167
+
168
+ The framework passes no ambient input to plugins. A plugin's entire input is the
169
+ `config` it is given and the pipeline it receives.
170
+
171
+ ## 10. Output
172
+
173
+ ### 10.1 What the framework writes
174
+
175
+ The framework itself writes:
176
+
177
+ - **a log**, to standard output (section 14),
178
+ - **the user directory**, when `scaffold` is asked for.
179
+
180
+ Everything a run actually accomplishes is written by a plugin: a file, a row in
181
+ SQLite, a request to a remote service, a line on the terminal. The framework
182
+ does not know what any of it is and does not verify it.
183
+
184
+ The final pipeline value is discarded. A Recipe whose last plugin only
185
+ transforms the pipeline has done nothing observable, and that is not an error.
186
+
187
+ ### 10.2 What a run publishes
188
+
189
+ Where the collected information ends up is decided by the Recipe, and the
190
+ plugins that decide it are the `Publish` category. Two kinds of destination have
191
+ always existed side by side: a remote service, reached with a credential over
192
+ the network, and something local — the terminal, a file, a database. Neither is
193
+ the system's purpose. Automatic Ruby is not a means of feeding one particular
194
+ reader, one particular service or one particular protocol, and a requirement
195
+ written as though it were would be wrong about what the system is for.
196
+
197
+ Leaving the collected information behind as a **document that outlives the run**
198
+ is as much a use of the system as sending it somewhere. That document is read by
199
+ a person, kept in version control, searched with ordinary Unix tools, and given
200
+ to a program — including a large language model or an agent — as input to
201
+ summarize, classify or reorganize. These are the same document and the same
202
+ requirement, not two.
203
+
204
+ **Markdown is the standard publication format** for that purpose, in the sense
205
+ that it is what a Recipe publishes when it has no reason to publish to a
206
+ particular service. Its properties are the reason, and they are technical ones:
207
+
208
+ - a person reads it as it is, with no tool and no rendering step;
209
+ - it diffs and merges, so a growing document belongs in Git;
210
+ - `grep`, `sed` and the rest operate on it without a parser;
211
+ - a program consumes it as text, which is the form a language model or an agent
212
+ takes its input in;
213
+ - it needs no service, no account, no credential and no network;
214
+ - it is plain text, so it stays readable for as long as the filesystem does.
215
+
216
+ The requirements that follow:
217
+
218
+ - **A publication format shall be available that needs nothing outside the
219
+ machine.** A Recipe that collects, filters and de-duplicates shall be able to
220
+ finish by writing what it has, without an account anywhere.
221
+ - **Markdown output is an ordinary Publish plugin**, subject to the plugin
222
+ contract of section 7.2 and to nothing else. It is not a framework feature,
223
+ the framework gains no knowledge of Markdown, and section 23 applies to it as
224
+ to anything else.
225
+ - **Nothing is published implicitly.** A Recipe publishes what it names and
226
+ nothing more. The framework shall not append a publishing step to a Recipe
227
+ that does not ask for one, and a Recipe written before Markdown output existed
228
+ shall behave exactly as it did.
229
+ - **The output format is not the pipeline value.** Serializing to Markdown
230
+ happens at the boundary, in the publishing plugin, and leaves section 8's
231
+ value untouched. No second representation is introduced, and the plugins
232
+ before and after are unaffected.
233
+ - **RSS and Atom remain input formats.** `SubscriptionFeed` and the rest are
234
+ unaffected by any of this: a feed is still one of the ordinary ways to acquire
235
+ something, and none of it is deprecated. That the pipeline value has the shape
236
+ of a feed is an internal matter (section 8); it neither obliges a run to
237
+ publish a feed nor makes feeds less useful to read.
238
+
239
+ ## 11. The command line
240
+
241
+ One executable, `automatic`, with two modes.
242
+
243
+ **Running a Recipe.** `automatic -c RECIPE` loads the Recipe and runs its
244
+ pipeline. `RECIPE` is either a path, or a bare name resolved inside
245
+ `~/.automatic/config`.
246
+
247
+ **Subcommands.** Auxiliary tools that do not involve a Recipe:
248
+
249
+ | Subcommand | What it is for |
250
+ | --- | --- |
251
+ | `scaffold` | Create the user directory (section 13) |
252
+ | `unscaffold` | Remove the user directory |
253
+ | `autodiscovery <url>` | Print the feed URLs advertised by a page |
254
+ | `feedparser <url>` | Parse a feed and print the result |
255
+ | `inspect <url>` | Discover a page's feeds, then parse the first |
256
+ | `opmlparser <path>` | Print the feed URLs in an OPML file |
257
+ | `log <level> <message>` | Emit one line in the framework's log format |
258
+
259
+ The last five exist to answer "will this work as a Recipe input?" before writing
260
+ the Recipe. They are diagnostic and they may print freely.
261
+
262
+ Requirements on the command line:
263
+
264
+ - `--help` and `--version` shall be available, shall print to standard output
265
+ and shall exit `0`.
266
+ - Exit status shall distinguish success from failure: `0` when the requested
267
+ work was done, `1` when it was not, `2` when the command line itself was
268
+ rejected. A run that fails shall not exit `0`.
269
+ - Diagnostics shall go to standard error, so that the output of `feedparser` and
270
+ friends can be redirected without collecting them.
271
+ - The set of subcommands and the meaning of `-c` are part of the compatibility
272
+ promise of section 7.1.
273
+
274
+ ## 12. Execution and failure
275
+
276
+ A run is: load the Recipe, then for each plugin entry in order, load the class,
277
+ construct it with its `config` and the current pipeline, call `run`, and take
278
+ the result as the pipeline for the next entry.
279
+
280
+ The requirements on failure:
281
+
282
+ - **A pipeline is not partially rerunnable.** There is no checkpoint and no
283
+ resume. A failed run is rerun from the start, so plugins that must not repeat
284
+ their effect are responsible for saying so; `StorePermalink` exists for this.
285
+ - **A plugin that raises ends the run.** The framework does not catch exceptions
286
+ from plugins. The Recipe is a sequence in which each step consumes the
287
+ previous one's output, so continuing past a failed step would run the
288
+ remaining steps on a value their author never intended.
289
+ - **Retrying belongs to the plugin.** Plugins that reach the network take
290
+ `retry` and `interval` and handle their own transient failures. The framework
291
+ offers no retry, and shall not acquire one that changes what a Recipe means.
292
+ - **A failure shall be visible.** Whatever a plugin decides to do about an
293
+ error, it logs it, and the process exit status reflects whether the run
294
+ completed. Silently returning an empty pipeline is a defect.
295
+ - **A Recipe naming a plugin that does not exist fails immediately**, before any
296
+ plugin runs, with a message naming the plugin.
297
+
298
+ ## 13. The user directory
299
+
300
+ `~/.automatic` is where an installation's own material lives, so that it
301
+ survives upgrading or reinstalling the gem.
302
+
303
+ | Path | Holds |
304
+ | --- | --- |
305
+ | `~/.automatic/config` | Recipes. A bare `-c` name is resolved here. |
306
+ | `~/.automatic/plugins` | The operator's own plugins, in category subdirectories. |
307
+ | `~/.automatic/db` | SQLite databases written by the store plugins. |
308
+ | `~/.automatic/assets` | Data files plugins need, such as the fulltext siteinfo. |
309
+
310
+ Requirements:
311
+
312
+ - The user directory takes precedence over the installed package. A plugin under
313
+ `~/.automatic/plugins` shadows a plugin of the same name shipped in the gem.
314
+ Overriding a shipped plugin without editing the installation is the point.
315
+ - Each part is optional. Where a directory is absent the framework falls back to
316
+ the corresponding directory inside the installation, and a Recipe that needs
317
+ none of them runs without a user directory at all.
318
+ - `scaffold` creates the directory and seeds it: the category subdirectories, the
319
+ example Recipes, and the shipped assets. It shall not overwrite what is
320
+ already there.
321
+ - `unscaffold` removes the whole directory, including Recipes and databases the
322
+ operator put there. It is destructive by design and says so.
323
+
324
+ ## 14. Logging
325
+
326
+ One log, to standard output, one line per event, through Ruby's `Logger`.
327
+
328
+ - Four levels, in order: `info`, `warn`, `error`, `none`. `none` silences
329
+ everything.
330
+ - The level is set per Recipe, by `global.log.level`. It is the one framework
331
+ setting a Recipe carries.
332
+ - The framework logs which Recipe was loaded and which database was opened.
333
+ Plugins log what they fetched, what they skipped and what failed.
334
+ - The log is what the operator has after an unattended run. Where a plugin
335
+ decides not to fail, the log is the only record that anything went wrong, so
336
+ a swallowed error is logged at `warn` or `error`, never at `info` and never
337
+ not at all.
338
+
339
+ ## 15. Scheduling
340
+
341
+ Out of scope, deliberately. The system provides no scheduler, no daemon, no lock
342
+ file and no "run every N minutes". `automatic -c recipe.yml` in a `cron` entry
343
+ is the intended deployment, and `cron` keeps the responsibility for when.
344
+
345
+ Two consequences the operator owns: overlapping runs are possible if a run
346
+ outlasts its interval, and a Recipe that reaches a rate-limited service is
347
+ scheduled with that in mind.
348
+
349
+ ## 16. External services
350
+
351
+ The framework reaches nothing. Plugins reach the network, and where they do:
352
+
353
+ - The endpoint, the protocol, the authentication and the failure handling belong
354
+ entirely to that plugin.
355
+ - Credentials are given to the plugin in its Recipe `config` (section 17).
356
+ - TLS certificates shall be verified. A plugin shall not disable verification.
357
+ - Nothing is fetched on the framework's own initiative: no update check, no
358
+ telemetry, no phone-home.
359
+
360
+ Services shut down and APIs are replaced, and whether a given plugin still works
361
+ is a fact about the outside world rather than about this repository. It is
362
+ recorded per plugin in [`PLUGINS.md`](PLUGINS.md) section 6 and kept current
363
+ there. Three requirements follow:
364
+
365
+ - **A shipped plugin shall be practically usable on the supported Ruby
366
+ versions.** Shipping it is a statement that it does what its entry says,
367
+ given what that entry says the operator must provide. A plugin for which that
368
+ statement can no longer be made shall be corrected or removed; it shall not
369
+ be carried indefinitely as a record of what once existed, which is what the
370
+ version control history is for.
371
+ - **A plugin that cannot work shall say so** — in the catalogue and, where it
372
+ runs at all, in its log output. It shall not be quietly left to fail at
373
+ runtime.
374
+ - **A plugin shall never be made to pass a test by simulating a service that no
375
+ longer exists.** Deleting the test, or marking it as requiring a service that
376
+ is gone, is correct; a stub that makes a dead integration look alive is not.
377
+
378
+ ## 17. Trust and credentials
379
+
380
+ **A Recipe is trusted local configuration**, equivalent to a shell script the
381
+ operator wrote. This is the trust boundary, and everything below follows from
382
+ it.
383
+
384
+ A Recipe names Ruby classes and the framework loads and runs them. A Recipe also
385
+ names plugin *files*, indirectly, through the loader's search path. Anyone who
386
+ can write a Recipe, or write a file under `~/.automatic/plugins`, can execute
387
+ arbitrary code as the operator. Therefore:
388
+
389
+ - Recipes and plugin directories are the operator's own, protected by file
390
+ permissions. There is no supported use in which they come from elsewhere.
391
+ - A Recipe from an untrusted source is not to be run, and the system offers no
392
+ mode in which doing so would be safe. This is not a limitation to be lifted by
393
+ hardening the YAML parser.
394
+ - Even so, the Recipe parser shall not be a second, avoidable path to code
395
+ execution: YAML shall be loaded so that the document cannot name arbitrary
396
+ Ruby classes to instantiate. Refusing that costs nothing, since no Recipe
397
+ needs it.
398
+
399
+ Credentials — API tokens, passwords, keys — are values in the Recipe's `config`,
400
+ which means a Recipe holding them is a secret file, and the operator restricts
401
+ its permissions. This is a weakness inherited from the original design, and it
402
+ is recorded rather than glossed:
403
+
404
+ - No credential shall be committed to this repository, in a Recipe, an example,
405
+ a fixture or a test.
406
+ - No credential shall be written to the log. A plugin that logs its own settings
407
+ is a defect.
408
+ - The example Recipes shipped in `config/` shall need no credential.
409
+
410
+ ## 18. Persistence
411
+
412
+ Two kinds, both a plugin's business rather than the framework's.
413
+
414
+ **SQLite, through ActiveRecord.** The store plugins keep a table of what has
415
+ been seen so that the next run skips it: `StorePermalink` on the link,
416
+ `StoreFullText` on the link and title with the article body. The database file
417
+ is named in the Recipe and lives in `~/.automatic/db`, or in the installation's
418
+ `db/` when there is no user directory. The table is created on first use from
419
+ the plugin's own column definition; there are no migrations and no schema
420
+ version.
421
+
422
+ Requirements: ActiveRecord is used as a library and this is not a Rails
423
+ application; nothing shall introduce one. Equally, ActiveRecord shall not be
424
+ replaced by a hand-written database layer merely because it is a large
425
+ dependency — it is what the existing databases were written by, and operators
426
+ have those files. It is the store plugins' dependency and not the framework's:
427
+ `activerecord` and `sqlite3` shall be installed by the operator who uses those
428
+ plugins, and a Recipe that stores nothing shall run without either.
429
+
430
+ **The filesystem.** `StoreFile` downloads what the pipeline points at and
431
+ rewrites the item's link to a `file://` URI, which is how a later publishing
432
+ plugin knows to upload a local file rather than a remote one.
433
+
434
+ ## 19. Packaging and distribution
435
+
436
+ - Distributed as the RubyGem `automatic`, installable with `gem install
437
+ automatic`, providing the `automatic` executable.
438
+ - Also usable from a source checkout, through `bundle exec bin/automatic`.
439
+ - Dependencies are declared, versioned and resolved by Bundler and RubyGems.
440
+ Nothing is vendored into this repository.
441
+ - **The core install shall be small.** A dependency needed by one plugin shall
442
+ not be required to install the framework or to run a Recipe that does not use
443
+ that plugin; see [`POLICY.md`](POLICY.md) section 9. Requiring an operator who
444
+ publishes to a console to install an AWS SDK is a defect.
445
+ - Gem sources shall be HTTPS and shall be currently operating.
446
+
447
+ ## 20. Supported Ruby
448
+
449
+ Two statements are made here, and they are deliberately different.
450
+
451
+ **The supported range** is **Ruby 3.3 through Ruby 4.0**.
452
+
453
+ - The floor is **Ruby 3.3**: the oldest maintained release the dependency set is
454
+ resolved and tested against. Nothing older is tested or supported.
455
+ - The code shall be written against APIs the whole range shares. Where a Ruby
456
+ release deprecates or removes one, the replacement that works on the whole
457
+ range is used, rather than a `RUBY_VERSION` branch; see
458
+ [`POLICY.md`](POLICY.md) section 2.4.
459
+ - The gemspec's `required_ruby_version` is a lower bound and not an upper one,
460
+ so a Ruby newer than the range is permitted rather than refused. Refusing one
461
+ would need a new release of this gem to lift.
462
+ - The floor shall not be lowered to accommodate an unmaintained Ruby, and shall
463
+ not be raised to the newest release for its own sake. It moves when a
464
+ dependency the project needs moves it, or when the version drops out of the
465
+ distributions the project is used on.
466
+
467
+ **The continuously validated versions** are **3.3, 3.4 and 4.0** — the ends of
468
+ the range and the release in the middle.
469
+
470
+ - CI runs representative versions rather than every intermediate release. The
471
+ cost of a matrix entry is paid on every commit, and a third entry between two
472
+ that pass says little about a range whose code shares one set of APIs.
473
+ - **A version's absence from the matrix is not a statement that it fails.** It
474
+ is a statement that it is not verified on every commit. Nothing is written to
475
+ be deliberately incompatible with a supported Ruby that the matrix omits.
476
+ - Adding a released Ruby to the matrix is how support for it becomes continuous,
477
+ and is a small change.
478
+
479
+ One statement of the supported range lives in the gemspec, one statement of the
480
+ validated set lives in the CI matrix, and the README and the documents agree
481
+ with both.
482
+
483
+ ## 21. Portability
484
+
485
+ - No compiled extension of this repository's own. Extensions come only from
486
+ dependencies.
487
+ - No absolute path outside `~/.automatic` and the installation directory.
488
+ - Nothing tied to a particular distribution, filesystem layout or init system.
489
+ - Shelling out is a plugin's business, and a plugin that does it is expected to
490
+ fail cleanly where the command is absent.
491
+
492
+ ## 22. Testability
493
+
494
+ - The framework's own units — the Recipe loader, the plugin loader, the
495
+ pipeline, the log — shall be testable without a network, without credentials
496
+ and without a user directory.
497
+ - **The default test suite shall reach no network and require no credential.**
498
+ A test that needs either is not part of it.
499
+ - A plugin's test constructs the plugin with a `config` and a pipeline, calls
500
+ `run`, and asserts on the returned pipeline. The framework provides the means
501
+ to build a pipeline for this.
502
+ - Where a plugin's dependency cannot be installed or its service no longer
503
+ exists, its test is excluded from the default suite by that fact and not by a
504
+ simulation of the service. See section 16.
505
+ - The integration Recipes under `test/integration` are run by hand against real
506
+ services, and are not part of the default suite or of CI.
507
+
508
+ ## 23. Simplicity
509
+
510
+ The framework is under seven hundred lines of Ruby and is meant to stay that
511
+ size. It is the small fixed part that plugins are written against, and it earns
512
+ its keep by not changing.
513
+
514
+ - A capability that can live in a plugin lives in a plugin.
515
+ - A framework feature that only one plugin would use does not belong to the
516
+ framework.
517
+ - Long-standing behaviour is left alone unless there is a reason beyond taste. A
518
+ pipeline that has been in `cron` for a decade has earned the benefit of the
519
+ doubt.
520
+
521
+ ## 24. Licence
522
+
523
+ Automatic Ruby is dual-licensed under the GNU General Public License, version 3,
524
+ or the GNU Lesser General Public License, version 3. A user may choose either
525
+ license at their discretion. See [`LICENSE.md`](LICENSE.md),
526
+ [`COPYING`](COPYING) and [`COPYING.LESSER`](COPYING.LESSER).