diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e12dcc8..c0b0cd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,18 @@ name: CI -# Installs the bundle and runs the test suite on every supported Ruby version. +# Installs the bundle, builds the gem and runs the default test suite on the +# Ruby versions this project validates continuously. +# +# The supported range and the validated set are two different statements. The +# code is written for Ruby 3.3 through 4.0, which is what required_ruby_version +# says; the matrix below runs the ends of that range and the release in the +# middle, rather than every intermediate release. See doc/POLICY.md section 11 +# and doc/REQUIREMENTS.md section 20. # # No credential is configured here and nothing reaches an external service: the -# suite must need neither (doc/POLICY.md Invariant 6). Specs tagged :network -# are excluded by default and are not run here. +# suite must need neither (doc/POLICY.md Invariant 6). Examples tagged :network +# are excluded by default, and the Gemfile's optional :plugins group is not +# installed, so no plugin's own gem is a condition of this workflow passing. on: push: @@ -22,9 +30,10 @@ jobs: strategy: fail-fast: false matrix: - # Keep this in step with required_ruby_version in automatic.gemspec - # and with the supported environment section of README.md. - ruby: ['3.3', '3.4', '3.5'] + # The continuously validated versions. Keep this in step with the + # supported environment section of README.md; the floor here and + # required_ruby_version in automatic.gemspec are the same version. + ruby: ['3.3', '3.4', '4.0'] steps: - uses: actions/checkout@v4 @@ -43,6 +52,9 @@ jobs: - name: Check that the gemspec is valid and buildable run: gem build automatic.gemspec + - name: Check that the library loads + run: bundle exec ruby -Ilib -e "require 'automatic'" + - name: Check that the CLI runs run: | bundle exec bin/automatic --version diff --git a/Gemfile b/Gemfile index 3eddbaf..cc077a8 100644 --- a/Gemfile +++ b/Gemfile @@ -11,10 +11,19 @@ gemspec # runtime dependencies of the gem: installing automatic does not install them, # and a Recipe that does not use the plugin does not need them. # -# Uncomment what you use in a checkout. The table of which plugin needs which -# gem, and which of those plugins still work, is in doc/DEPLOYMENT.md and -# doc/PLUGINS.md section 6. +# The group is optional, so `bundle install` does not install it and neither +# the default test suite nor CI depends on it. Install it deliberately, and the +# specs of the plugins that need it then run as part of the ordinary suite: +# +# BUNDLE_WITH=plugins bundle install +# bundle exec rake +# +# The table of which plugin needs which gem, and which of those plugins still +# work, is in doc/DEPLOYMENT.md and doc/PLUGINS.md section 6. group :plugins, optional: true do + gem 'nkf' # FilterDescriptionLink + gem 'sanitize' # FilterSanitize + # PublishAmazonS3 and the s3n:// path of StoreFile call AWS::S3, which only # AWS SDK for Ruby v1 provided. No currently published gem satisfies them, so # there is nothing to uncomment; they need rework. See doc/PLUGINS.md. diff --git a/README.md b/README.md index 5f6ce7d..2bb33bd 100644 --- a/README.md +++ b/README.md @@ -160,14 +160,23 @@ The full account is [`doc/BASIC_DESIGN.md`](doc/BASIC_DESIGN.md). ## 4. Supported environment -- **Ruby 3.3 or later.** Tested on 3.3, 3.4 and 3.5. +- **Ruby 3.3 through 4.0.** CI validates 3.3, 3.4 and 4.0. - A Unix-like system. GNU/Linux and macOS are what it is used on. Windows is not supported. - A compiler, if `nokogiri` or `sqlite3` build from source on your platform. Ruby 3.3 is the floor: it is the oldest maintained release the dependencies are -resolved and tested against. Nothing older is tested or supported. A newer Ruby than -the matrix covers is permitted by the gemspec, which sets a lower bound only. +resolved and tested against. Nothing older is tested or supported. + +Two statements, and they are not the same one: + +- **Supported range.** The code is written for Ruby 3.3 through 4.0, using APIs + the whole range shares. `required_ruby_version` is `>= 3.3.0` and has no upper + bound, so a Ruby newer than the matrix is permitted rather than refused. +- **Continuously validated versions.** CI runs the ends of the range and the + release in the middle — 3.3, 3.4 and 4.0 — rather than every intermediate + release. A version's absence from the matrix means it is not verified on every + commit; it does not mean it is expected to fail. ## 5. Installation @@ -463,6 +472,15 @@ COVERAGE=on bundle exec rake spec AUTOMATIC_NETWORK_SPECS=1 bundle exec rake spec ``` +- A plugin whose gem the Gemfile declares in its optional `:plugins` group is + **not verified by the default suite**, because that group is not installed. + Install it to run those specs as part of the ordinary suite: + + ```sh + BUNDLE_WITH=plugins bundle install + bundle exec rake + ``` + - A spec whose plugin needs a gem that is not installed is skipped, and says which gem is missing. That absence is the signal; a plugin whose service no longer exists is never stubbed into passing. @@ -471,8 +489,11 @@ COVERAGE=on bundle exec rake spec in CI. Most need a credential, a dead service, or both — read one before running it. -CI runs `bundle install` and the suite on every supported Ruby version, from -[`.github/workflows/ci.yml`](.github/workflows/ci.yml). +CI installs the bundle, builds the gem, loads the library, runs the CLI and runs +the default suite on each validated Ruby version, from +[`.github/workflows/ci.yml`](.github/workflows/ci.yml). It configures no secret +and installs no optional plugin gem, so no plugin's own dependency is a +condition of a green build. ## 13. Development diff --git a/automatic.gemspec b/automatic.gemspec index d527d77..efb7b4b 100644 --- a/automatic.gemspec +++ b/automatic.gemspec @@ -39,9 +39,12 @@ Gem::Specification.new do |spec| } # Ruby 3.3 is the floor: the oldest maintained release the dependencies are - # resolved and tested against. This is a lower bound only, so a newer Ruby is - # permitted before it reaches the CI matrix. See doc/REQUIREMENTS.md - # section 20. + # resolved and tested against. The code is written for Ruby 3.3 through 4.0. + # + # A lower bound only, and deliberately so. An upper bound would refuse a Ruby + # this code has every reason to work on, on the day it is released, and the + # only way to lift it would be a new release of this gem. What CI validates + # is a separate and narrower statement; see doc/REQUIREMENTS.md section 20. spec.required_ruby_version = '>= 3.3.0' # Shipped files. Derived from what Git sees, so that the list cannot drift @@ -83,24 +86,30 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.extra_rdoc_files = ['README.md', 'doc/LICENSE.md'] - # Runtime dependencies: what the framework itself needs, plus the store - # plugins, which nearly every Recipe uses to avoid repeating its work. + # Runtime dependencies: what the framework itself needs, plus what the + # documented primary workflow needs — the store plugins, which nearly every + # Recipe uses to avoid repeating its work, and the Markdown publisher. # # A gem needed by a single plugin is NOT declared here. It is required inside # that plugin's own file and installed by the operator who uses the plugin. # See doc/POLICY.md section 9.1 and doc/DEPLOYMENT.md. + # + # rexml and rss left the standard library and became gems over the 3.x + # series, and nkf followed after 3.3. Each gem listed here is listed because + # something committed here requires it, and a library's move out of the + # standard library is not by itself a reason to declare it: nkf is a plugin's + # dependency and is in the Gemfile's optional group instead. spec.add_dependency 'activerecord', '>= 7.1', '< 9.0' # store plugins spec.add_dependency 'activesupport', '>= 7.1', '< 9.0' # plugin loader, XML subscription spec.add_dependency 'feedbag', '>= 1.0', '< 2.0' # autodiscovery subcommand spec.add_dependency 'hashie', '>= 4.0', '< 6.0' # Recipe - # nkf stopped being a default gem in Ruby 3.4 and is needed by - # FilterDescriptionLink, which is a Supported plugin; it is a standard - # library extraction with no transitive dependencies. - spec.add_dependency 'nkf', '>= 0.1', '< 1.0' # FilterDescriptionLink - spec.add_dependency 'nokogiri', '>= 1.15', '< 2.0' # HTML parsing + # Used by no framework file on the way in: requiring `automatic` loads no + # HTML parser. It is here because Supported plugins that an installed gem + # must be able to run need it -- PublishMarkdown, and FeedParser.parse_html + # for SubscriptionLink and SubscriptionTumblr. + spec.add_dependency 'nokogiri', '>= 1.15', '< 2.0' # HTML parsing, in plugins spec.add_dependency 'rexml', '>= 3.2', '< 4.0' # OPML parser spec.add_dependency 'rss', '>= 0.3', '< 1.0' # the pipeline value - spec.add_dependency 'sanitize', '>= 6.0', '< 8.0' # FilterSanitize spec.add_dependency 'sqlite3', '>= 1.7', '< 3.0' # store plugins spec.add_development_dependency 'rake', '~> 13.0' diff --git a/doc/DEPLOYMENT.md b/doc/DEPLOYMENT.md index e905c00..c6b2690 100644 --- a/doc/DEPLOYMENT.md +++ b/doc/DEPLOYMENT.md @@ -28,8 +28,10 @@ nothing to stop. - A Unix-like system. GNU/Linux and macOS are what this is used on; Windows is not supported. -- **Ruby 3.3 or later.** Check with `ruby -v`. The supported versions are 3.3, - 3.4 and 3.5. +- **Ruby 3.3 through 4.0.** Check with `ruby -v`. CI validates 3.3, 3.4 and 4.0; + a version between them is supported and is simply not checked on every commit, + and a Ruby newer than 4.0 is permitted rather than refused. See + [`REQUIREMENTS.md`](REQUIREMENTS.md) section 20. - A build environment for native extensions, because `nokogiri` and `sqlite3` may build from source: @@ -395,6 +397,8 @@ several of these plugins talk to services that no longer exist. | Plugin | Needs | Status | | --- | --- | --- | +| `FilterSanitize` | `sanitize` | Supported | +| `FilterDescriptionLink` | `nkf` | Supported | | `CustomFeedSVNLog` | `xml-simple`, and the `svn` command | Supported (external) | | `ProvideFluentd`, `PublishFluentd` | `fluent-logger`, and a Fluentd instance | Supported (external) | | `PublishMemcached` | `dalli`, and a memcached server | Supported (external) | @@ -409,6 +413,8 @@ several of these plugins talk to services that no longer exist. | `SubscriptionWeather` | — | Unsupported | ```sh +gem install sanitize # for FilterSanitize +gem install nkf # for FilterDescriptionLink gem install fluent-logger # for the Fluentd plugins gem install dalli # for PublishMemcached gem install xml-simple # for CustomFeedSVNLog @@ -420,8 +426,16 @@ They call `AWS::S3`, which AWS SDK for Ruby version 1 provided and the current need rework. `StoreFile` makes that requirement lazily, so its ordinary HTTP download path works with no AWS gem installed at all. -In a checkout, uncomment the `plugins` group in the `Gemfile` instead and run -`bundle install`. +In a checkout, install the `Gemfile`'s optional `plugins` group instead — +uncommenting the entry first, where the gem is one of the commented ones: + +```sh +BUNDLE_WITH=plugins bundle install +``` + +That group is not installed by default and is not installed in CI, so these +plugins are outside what the default test suite verifies. Installing it also +brings their specs into the ordinary `bundle exec rake` run. ## Your own plugins diff --git a/doc/PLUGINS.md b/doc/PLUGINS.md index d7c822e..2c3bd32 100644 --- a/doc/PLUGINS.md +++ b/doc/PLUGINS.md @@ -388,7 +388,9 @@ module Automatic::Plugin That is what keeps a gem needed by one plugin out of everyone else's installation. A gem used by a single plugin is not added to the framework's -runtime dependencies; see [`POLICY.md`](POLICY.md) section 9. +runtime dependencies; it goes in the `Gemfile`'s optional `:plugins` group and +the operator who uses the plugin installs it. See [`POLICY.md`](POLICY.md) +section 9. Where a plugin has an optional capability that needs a heavier library — S3 support in `StoreFile`, for instance — the `require` goes inside the branch that @@ -520,6 +522,14 @@ Two rules govern this table, and they are the reason it exists at all: framework was used for, and several remain useful as templates for a replacement. Removal is a separate, deliberate decision. +**Supported is not the same as covered by CI.** A Supported plugin whose gem is +an optional plugin dependency — `FilterSanitize` and `FilterDescriptionLink` — +works, and is simply not part of what a green build guarantees, because the +default bundle does not install that gem. Its entry says so, and installing the +gem runs its spec as part of the ordinary suite. Nothing here is classified by +what CI happens to run; a plugin is not demoted for needing a gem, and is not +promoted by a test that CI never executes. + **This classification is a snapshot taken in August 2026,** based on the published status of each service and on what each plugin's code actually calls. The statuses in the "external service" rows depend on the outside world and can @@ -759,6 +769,11 @@ page the link points at. Fetching pages means network access. No settings. | --- | --- | --- | | `mode` | string | `basic`, `relaxed`, or `restricted`. Default `restricted`. | +Needs the `sanitize` gem, which is an optional plugin dependency and is not +installed with the framework. Its spec is therefore outside the default suite +and outside CI; installing the gem brings the spec back into the ordinary run. +See [`DEPLOYMENT.md`](DEPLOYMENT.md). + #### FilterTumblrResize — **Supported** `filter/tumblr_resize.rb`. Rewrites a Tumblr image link to the 1280-pixel @@ -779,6 +794,12 @@ the body. `get_title` makes one request per item; use `FilterOne` or a store plugin before it on a large feed. +Needs the `nkf` gem, which the plugin uses to normalize a fetched page's +encoding. `nkf` left the standard library after Ruby 3.3 and is an optional +plugin dependency rather than a framework one, so it is not installed with the +framework, and this plugin's spec is outside the default suite and outside CI. +See [`DEPLOYMENT.md`](DEPLOYMENT.md). + #### FilterFullFeed — **Supported (external)** `filter/full_feed.rb`. Replaces a summary with the article body, by matching the @@ -997,11 +1018,14 @@ arbitrary markup back into equivalent Markdown — tables, nested lists, inline links, images — is a large job with a large library behind it, and a library that size does not become a dependency for one plugin ([`POLICY.md`](POLICY.md) section 9.1). Reducing markup to text needs nothing -that is not already installed: `nokogiri` is a runtime dependency, used by the -framework's own feed adapters. The result is defined by its two ends — the -text survives, the markup does not — which is what both a reader and a program -reading the file want from it. A link inside a body becomes its own text; the -item's own link is in the metadata list, where nothing loses it. +beyond `nokogiri`, which `gem install automatic` installs: it is a runtime +dependency of this gem precisely so that the Supported plugins an installed gem +must be able to run — this one, and `FeedParser.parse_html` for +`SubscriptionLink` and `SubscriptionTumblr` — work with nothing else added. +Requiring `automatic` itself loads no HTML parser. The result is defined by its +two ends — the text survives, the markup does not — which is what both a reader +and a program reading the file want from it. A link inside a body becomes its +own text; the item's own link is in the metadata list, where nothing loses it. Where a different treatment is wanted, the pipeline already has the means: `FilterSanitize` before this plugin decides what markup survives into the diff --git a/doc/POLICY.md b/doc/POLICY.md index 5e75ad7..bc2f436 100644 --- a/doc/POLICY.md +++ b/doc/POLICY.md @@ -336,8 +336,17 @@ repository level only; see section 10. ### 2.4 Ruby version compatibility - The supported range is stated in one place, `automatic.gemspec` - (`required_ruby_version`), and the README, the CI matrix and the documents - agree with it. + (`required_ruby_version`), and the README and the documents agree with it. The + set CI validates is a narrower statement and lives in the matrix; section 11 + says how the two relate. +- **Compatibility is written in the range's common API.** Where a Ruby release + deprecates or removes something, the replacement chosen is the one that works + unchanged on every supported version. `URI::Parser#escape` becoming obsolete + is answered by naming `URI::RFC2396_Parser`, which means the same thing on all + of them — not by a `RUBY_VERSION` branch. +- **A `RUBY_VERSION` conditional is a last resort**, for a difference that has + no common expression. Two implementations of one behaviour cost more than the + compatibility they buy: the branch not taken is the branch not tested. - **Code for an unsupported Ruby is removed, not kept for safety.** A `RUBY_VERSION` comparison against 1.8 or 1.9, a branch for an interpreter that cannot install the dependencies, and a shim for a method that has been in core @@ -346,6 +355,12 @@ repository level only; see section 10. - A method removed by Ruby is replaced by its supported equivalent, and that is a compatibility fix rather than a refactor: `Kernel#open` on a URL becomes `URI.open`, `File.exists?` becomes `File.exist?`. +- **A library leaving the standard library is not by itself a reason to declare + it.** Ruby moves libraries to default and then to bundled gems as it goes. + Each one is judged on what actually requires it: the framework's own + requirement becomes a runtime dependency, a plugin's becomes an optional one + (section 9.1), and a requirement left over from code that no longer uses it is + deleted. ### 2.5 Requiring @@ -426,9 +441,27 @@ Plugins outlive the services they talk to. The policy for what happens then: that no longer serve what they expect, which is a further reason not to make them a gate. A new example that reaches a host is tagged; one that does not is never given the tag to make a failure go away. +- **The default suite does not depend on an optional plugin gem.** A gem the + `Gemfile` declares in its optional `:plugins` group is not installed by + `bundle install`, so the plugins that need it are not verified by the default + suite or by CI. That is a decision, taken here, and not something a failure + discovers: the gems it applies to are the declared list + `AutomaticSpec::OPTIONAL_PLUGIN_GEMS`, a spec whose plugin needs one guards + its file with `AutomaticSpec.optional_dependency?`, and naming a gem that is + not on the list raises rather than skipping. Installing the group with + `BUNDLE_WITH=plugins bundle install` runs those specs as part of the ordinary + suite. - A spec whose plugin's gem is not installed is skipped by `AutomaticSpec.plugin_available?`, which names the missing gem. That is the intended behaviour and is not worked around by faking the gem. +- **The default suite is kept small and reliable rather than large.** It reaches + no network, needs no credential, needs no external daemon, writes outside no + temporary directory of its own, redirects `HOME`, and does not depend on + filesystem ordering, on the clock or on a random seed. A test that cannot be + made repeatable does not belong in it. Guaranteeing fewer things reliably is + the better trade, and it is not the same as weakening a test: `|| true`, + `continue-on-error` and a rescue that swallows a failure are forbidden, and + the tests that remain are held strictly. - **A spec does not write outside its own temporary directory.** Where a plugin resolves a path under the home directory, the spec redirects `HOME` to a temporary directory rather than operating on the developer's real @@ -530,12 +563,23 @@ not an accident: **Runtime dependencies** — declared in `automatic.gemspec`, installed by `gem install automatic`. A gem is here only if the framework itself uses it, or if a -plugin that the majority of Recipes use needs it. +plugin that the majority of Recipes use — or that the documented primary +workflow runs — needs it. A gem that reached this list because of a plugin that +no longer uses it, or because a Ruby release moved a library out of the standard +library, is moved back out. **Optional dependencies** — used by one plugin or a few, required inside the -plugin's own file, and **not declared as runtime dependencies**. An operator who -uses that plugin installs the gem. This is Invariant 4, and it is why installing -this gem does not install an AWS SDK. +plugin's own file, declared in the `Gemfile`'s optional `:plugins` group, and +**not declared as runtime dependencies**. An operator who uses that plugin +installs the gem. This is Invariant 4, and it is why installing this gem does +not install an AWS SDK. + +- **An unsupported or optional integration does not decide a framework-wide + dependency.** Where one plugin needs a gem, that gem is the plugin's, however + useful the plugin is. +- Being in this group has a consequence that is intended: the default suite and + CI do not install it, so those plugins are not part of what a green build + guarantees. See section 5. **Development dependencies** — the test and build tooling. @@ -704,11 +748,25 @@ the version history records what it amounts to. ## 11. Continuous integration - CI runs on GitHub Actions, from `.github/workflows/ci.yml`. -- It installs the bundle and runs the default test suite on each supported Ruby - version. A version in the matrix and a version in `required_ruby_version` are - the same set. +- **CI validates representative supported Ruby versions rather than every + intermediate release.** The matrix runs the ends of the supported range and + the release in the middle. The matrix and `required_ruby_version` are + therefore *not* the same set, and neither is wrong: the gemspec states what + the code is written for, the matrix states what is checked on every commit. + [`REQUIREMENTS.md`](REQUIREMENTS.md) section 20 states both. +- Removing a version from the matrix is not a statement that it fails, and no + incompatibility is introduced to make it one. +- What CI does is: install the bundle, build the gem, load the library, run the + command line, run the default suite. It is deliberately short, and an optional + integration is not added to it. - **CI holds no secret and reaches no external service.** No credential is configured, and no integration test against a third-party API is run there. +- **CI installs no optional plugin gem**, so no plugin's own dependency is a + condition of a green build. Where an optional integration is worth testing at + all, it is tested separately from the required workflow; section 5 says how. +- **A failure is fixed, not silenced.** `|| true`, `continue-on-error` and a + step that hides its exit status are not how a build is made green. Narrowing + what is guaranteed is a legitimate answer; pretending to guarantee it is not. - The Jenkins instance the project used until 2015 is gone. References to it have been removed and are not to be reintroduced. - A red build is fixed or reverted. It is not left red. diff --git a/doc/QUICKSTART.md b/doc/QUICKSTART.md index 40fc413..f1a5873 100644 --- a/doc/QUICKSTART.md +++ b/doc/QUICKSTART.md @@ -2,11 +2,11 @@ This guide takes public information through one short Automatic Ruby pipeline and leaves it as Markdown. It needs no account, credential, paid service or -database server. +database server, and no gem beyond the ones `gem install automatic` brings. ## 1. Install -Use Ruby 3.3, 3.4 or 3.5 on a Unix-like system: +Use Ruby 3.3 through 4.0 on a Unix-like system: ```sh ruby -v diff --git a/doc/REQUIREMENTS.md b/doc/REQUIREMENTS.md index 1a576e9..ef9006a 100644 --- a/doc/REQUIREMENTS.md +++ b/doc/REQUIREMENTS.md @@ -439,20 +439,39 @@ plugin knows to upload a local file rather than a remote one. ## 20. Supported Ruby +Two statements are made here, and they are deliberately different. + +**The supported range** is **Ruby 3.3 through Ruby 4.0**. + - The floor is **Ruby 3.3**: the oldest maintained release the dependency set is resolved and tested against. Nothing older is tested or supported. -- The versions upstream currently maintains are the recommended ones. The - supported set is what the CI matrix runs, which at the time of writing is - 3.3, 3.4 and 3.5. -- The gemspec's `required_ruby_version` is a lower bound and not an upper one, so - a newer Ruby is permitted before it has been added to the matrix. Adding one - to the matrix is the act of supporting it. +- The code shall be written against APIs the whole range shares. Where a Ruby + release deprecates or removes one, the replacement that works on the whole + range is used, rather than a `RUBY_VERSION` branch; see + [`POLICY.md`](POLICY.md) section 2.4. +- The gemspec's `required_ruby_version` is a lower bound and not an upper one, + so a Ruby newer than the range is permitted rather than refused. Refusing one + would need a new release of this gem to lift. - The floor shall not be lowered to accommodate an unmaintained Ruby, and shall not be raised to the newest release for its own sake. It moves when a dependency the project needs moves it, or when the version drops out of the distributions the project is used on. -- One statement of the supported range lives in the gemspec, and the README, the - CI matrix and the documents agree with it. + +**The continuously validated versions** are **3.3, 3.4 and 4.0** — the ends of +the range and the release in the middle. + +- CI runs representative versions rather than every intermediate release. The + cost of a matrix entry is paid on every commit, and a third entry between two + that pass says little about a range whose code shares one set of APIs. +- **A version's absence from the matrix is not a statement that it fails.** It + is a statement that it is not verified on every commit. Nothing is written to + be deliberately incompatible with a supported Ruby that the matrix omits. +- Adding a released Ruby to the matrix is how support for it becomes continuous, + and is a small change. + +One statement of the supported range lives in the gemspec, one statement of the +validated set lives in the CI matrix, and the README and the documents agree +with both. ## 21. Portability diff --git a/doc/VERSIONS b/doc/VERSIONS index 95645cd..3a091b7 100644 --- a/doc/VERSIONS +++ b/doc/VERSIONS @@ -3,11 +3,11 @@ automaticruby Repository Version History v26.08 (Release Date: TBD) -------------------------- -- Support Ruby 3.3 through 3.5 and modernize the codebase for current Ruby and maintained library APIs. +- Support Ruby 3.3 through 4.0 and modernize the codebase for current Ruby and maintained library APIs, validating representative versions in CI. - Harden Recipe loading with safe YAML parsing, structural validation and framework-specific errors. - Restructure the CLI with help and version options, predictable error reporting and documented exit statuses. - Verify TLS certificates when publishing to Instapaper instead of accepting an unverified connection. -- Modernize gem packaging and dependency policy, isolating optional plugin dependencies and excluding development and generated files. +- Modernize gem packaging and dependency policy, isolating optional plugin dependencies outside the framework and the default test path, and excluding development and generated files. - Classify every shipped plugin by its current support status rather than simulating obsolete services in tests. - Rebuild the test and CI strategy for current RSpec and Ruby, with deterministic isolation from user data and external services. - Add Markdown as the primary service-independent publication format, with a documented and tested first-run workflow. diff --git a/lib/automatic/feed_maker.rb b/lib/automatic/feed_maker.rb index 0f354cf..5832d55 100644 --- a/lib/automatic/feed_maker.rb +++ b/lib/automatic/feed_maker.rb @@ -12,7 +12,6 @@ module Automatic module FeedMaker require 'rss' require 'uri' - require 'nokogiri' class FeedObject attr_accessor :title, :link, :description, :author, :comments diff --git a/lib/automatic/feed_parser.rb b/lib/automatic/feed_parser.rb index 4f38be1..921a516 100644 --- a/lib/automatic/feed_parser.rb +++ b/lib/automatic/feed_parser.rb @@ -13,7 +13,6 @@ module Automatic module FeedParser - require 'nokogiri' require 'open-uri' require 'rss' require 'uri' @@ -32,7 +31,13 @@ def self.get_url(url) # Build a feed whose items are the links of an HTML document. This is how # a page that publishes no feed enters the pipeline. + # + # nokogiri is required here rather than at the top of the file: it is the + # only thing in the framework that wants an HTML parser, and requiring + # `automatic` should not load one. See doc/POLICY.md section 2.5. def self.parse_html(html) + require 'nokogiri' + RSS::Maker.make('2.0') do |maker| maker.xml_stylesheets.new_xml_stylesheet maker.channel.title = 'Automatic Ruby' diff --git a/lib/automatic/opml.rb b/lib/automatic/opml.rb index ab3f99a..82dacb0 100644 --- a/lib/automatic/opml.rb +++ b/lib/automatic/opml.rb @@ -220,7 +220,9 @@ def each_outline end # each_outline def read_text - text = "" + # +'' rather than "": this string is appended to below, and a literal + # is on its way to being frozen. + text = +'' while event = @p.pull case event.event_type when :end_element diff --git a/plugins/filter/absolute_uri.rb b/plugins/filter/absolute_uri.rb index d8c9cf8..1b3fa9d 100644 --- a/plugins/filter/absolute_uri.rb +++ b/plugins/filter/absolute_uri.rb @@ -5,11 +5,12 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Jun 20, 2012 -# Updated:: Oct 29, 2014 +# Updated:: Aug 14, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. module Automatic::Plugin class FilterAbsoluteURI + require 'uri' def initialize(config, pipeline=[]) @config = config @@ -39,7 +40,7 @@ def rewrite(string) @config['url'] = @config['url'] + '/' end string = @config['url'] + string.sub(/^\./,'').sub(/^\//,'') - string = URI::Parser.new.escape(string) + string = URI::RFC2396_Parser.new.escape(string) return string end end diff --git a/plugins/filter/description_link.rb b/plugins/filter/description_link.rb index c292016..894a4fa 100644 --- a/plugins/filter/description_link.rb +++ b/plugins/filter/description_link.rb @@ -16,6 +16,12 @@ class FilterDescriptionLink require 'open-uri' require 'uri' + # URI.extract and URI::PATTERN answer through the RFC 3986 parser that + # URI::Parser became in Ruby 3.4, which reports both as obsolete. The RFC + # 2396 parser is what they were always reaching, it is spelled the same way + # on every supported Ruby, and it is named here directly. + PARSER = URI::RFC2396_Parser.new + def initialize(config, pipeline=[]) @config = config @pipeline = pipeline @@ -40,7 +46,7 @@ def run def get_title(url) new_title = nil if url.class == String - url.gsub!(Regexp.new("[^#{URI::PATTERN::ALNUM}\/\:\?\=&~,\.\(\)#]")) {|match| ERB::Util.url_encode(match)} + url.gsub!(Regexp.new("[^#{URI::RFC2396_Parser::PATTERN::ALNUM}\/\:\?\=&~,\.\(\)#]")) {|match| ERB::Util.url_encode(match)} begin read_data = NKF.nkf("--utf8", URI.open(url).read) get_text = Nokogiri::HTML.parse(read_data, nil, 'utf8').xpath('//title').text @@ -54,7 +60,7 @@ def get_title(url) end def rewrite_link(feed) - new_link = URI.extract(feed.description, %w{http https}).uniq.last + new_link = PARSER.extract(feed.description, %w{http https}).uniq.last feed.link = new_link unless new_link.nil? if @config.class == Hash diff --git a/plugins/filter/image_source.rb b/plugins/filter/image_source.rb index af495ea..ff017cf 100644 --- a/plugins/filter/image_source.rb +++ b/plugins/filter/image_source.rb @@ -10,7 +10,6 @@ module Automatic::Plugin class FilterImageSource - require 'kconv' require 'net/http' require 'nokogiri' require 'open-uri' diff --git a/plugins/subscription/g_guide.rb b/plugins/subscription/g_guide.rb index 4183591..50551bd 100644 --- a/plugins/subscription/g_guide.rb +++ b/plugins/subscription/g_guide.rb @@ -5,11 +5,13 @@ # License:: The GPL version 3, or LGPL version 3 (Dual License). # Contact:: idnanashi@gmail.com # Created:: Jun 28, 2013 -# Updated:: Oct 29, 2014 +# Updated:: Aug 14, 2026 # Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. module Automatic::Plugin class SubscriptionGGuide + require 'uri' + G_GUIDE_RSS = 'http://tv.so-net.ne.jp/rss/schedulesBySearch.action?' def initialize(config, pipeline=[]) @@ -44,7 +46,7 @@ def feed_url keyword = nil feed += "condition.keyword=#{keyword}&" end feed += station_param - URI::Parser.new.escape(feed) + URI::RFC2396_Parser.new.escape(feed) end def station_param diff --git a/plugins/subscription/link.rb b/plugins/subscription/link.rb index 5c4fcaa..caf8323 100644 --- a/plugins/subscription/link.rb +++ b/plugins/subscription/link.rb @@ -24,7 +24,7 @@ def run retries = 0 retry_max = @config['retry'].to_i || 0 begin - create_rss(URI::Parser.new.escape(url)) + create_rss(URI::RFC2396_Parser.new.escape(url)) rescue retries += 1 Automatic::Log.puts("error", "ErrorCount: #{retries}, Fault in parsing: #{url}") diff --git a/plugins/subscription/xml.rb b/plugins/subscription/xml.rb index 4a005b4..0b03f19 100644 --- a/plugins/subscription/xml.rb +++ b/plugins/subscription/xml.rb @@ -27,7 +27,7 @@ def run retries = 0 retry_max = @config['retry'].to_i || 0 begin - create_rss(URI::Parser.new.escape(url)) + create_rss(URI::RFC2396_Parser.new.escape(url)) rescue retries += 1 Automatic::Log.puts("error", "ErrorCount: #{retries}, Fault in parsing: #{url}") diff --git a/spec/plugins/filter/description_link_spec.rb b/spec/plugins/filter/description_link_spec.rb index 3d982d4..7fc2f2f 100644 --- a/spec/plugins/filter/description_link_spec.rb +++ b/spec/plugins/filter/description_link_spec.rb @@ -10,131 +10,136 @@ require File.expand_path(File.dirname(__FILE__) + '../../../spec_helper') -require 'filter/description_link' - -describe Automatic::Plugin::FilterDescriptionLink do +# FilterDescriptionLink needs the nkf gem, which the Gemfile declares in its +# optional :plugins group. The default suite and CI do not install it, so this +# spec runs only where the operator has. See doc/POLICY.md section 5. +if AutomaticSpec.optional_dependency?('nkf') + require 'filter/description_link' + + describe Automatic::Plugin::FilterDescriptionLink do + + context "It should be rewrite link based on the description" do + + subject { + Automatic::Plugin::FilterDescriptionLink.new( + {}, + AutomaticSpec.generate_pipeline { + feed { + item "http://test1.id774.net", + "dummy title", + "aaa bbb ccc http://test2.id774.net ddd eee", + "Mon, 07 Mar 2011 15:54:11 +0900" + } + } + ) + } - context "It should be rewrite link based on the description" do + describe "#run" do + its(:run) { should have(1).feeds } - subject { - Automatic::Plugin::FilterDescriptionLink.new( - {}, - AutomaticSpec.generate_pipeline { - feed { - item "http://test1.id774.net", - "dummy title", - "aaa bbb ccc http://test2.id774.net ddd eee", - "Mon, 07 Mar 2011 15:54:11 +0900" - } + specify { + subject.run + subject.instance_variable_get(:@pipeline)[0].items[0].link. + should == "http://test2.id774.net" + subject.instance_variable_get(:@pipeline)[0].items[0].description. + should == "aaa bbb ccc http://test2.id774.net ddd eee" } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@pipeline)[0].items[0].link. - should == "http://test2.id774.net" - subject.instance_variable_get(:@pipeline)[0].items[0].description. - should == "aaa bbb ccc http://test2.id774.net ddd eee" - } + end end - end - context "It should be empty description if clear_description specified" do - - subject { - Automatic::Plugin::FilterDescriptionLink.new({ - 'clear_description' => 1, - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://test1.id774.net", - "dummy title", - "aaa bbb ccc http://test2.id774.net ddd eee", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should be empty description if clear_description specified" do + + subject { + Automatic::Plugin::FilterDescriptionLink.new({ + 'clear_description' => 1, + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://test1.id774.net", + "dummy title", + "aaa bbb ccc http://test2.id774.net ddd eee", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@pipeline)[0].items[0].link. - should == "http://test2.id774.net" - subject.instance_variable_get(:@pipeline)[0].items[0].description. - should == "" + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@pipeline)[0].items[0].link. + should == "http://test2.id774.net" + subject.instance_variable_get(:@pipeline)[0].items[0].description. + should == "" + } + end end - end - context "It should be got title if get_title specified", :network do - - subject { - Automatic::Plugin::FilterDescriptionLink.new({ - 'get_title' => 1, - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://test1.id774.net", - "dummy title", - "aaa bbb ccc http://blog.id774.net/post/2014/10/01/531/ ddd eee", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should be got title if get_title specified", :network do + + subject { + Automatic::Plugin::FilterDescriptionLink.new({ + 'get_title' => 1, + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://test1.id774.net", + "dummy title", + "aaa bbb ccc http://blog.id774.net/post/2014/10/01/531/ ddd eee", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@pipeline)[0].items[0].link. - should == "http://blog.id774.net/post/2014/10/01/531/" - subject.instance_variable_get(:@pipeline)[0].items[0].title. - should == "二穂様は俺の嫁 | 774::Blog" - subject.instance_variable_get(:@pipeline)[0].items[0].description. - should == "aaa bbb ccc http://blog.id774.net/post/2014/10/01/531/ ddd eee" + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@pipeline)[0].items[0].link. + should == "http://blog.id774.net/post/2014/10/01/531/" + subject.instance_variable_get(:@pipeline)[0].items[0].title. + should == "二穂様は俺の嫁 | 774::Blog" + subject.instance_variable_get(:@pipeline)[0].items[0].description. + should == "aaa bbb ccc http://blog.id774.net/post/2014/10/01/531/ ddd eee" + } + end end - end - context "It should be handling error if 404 Not Found", :network do - - subject { - Automatic::Plugin::FilterDescriptionLink.new({ - 'get_title' => 1, - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://test1.id774.net", - "dummy title", - "aaa bbb ccc http://blog.id774.net/post/2014/10/01/532/ ddd eee", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should be handling error if 404 Not Found", :network do + + subject { + Automatic::Plugin::FilterDescriptionLink.new({ + 'get_title' => 1, + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://test1.id774.net", + "dummy title", + "aaa bbb ccc http://blog.id774.net/post/2014/10/01/532/ ddd eee", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@pipeline)[0].items[0].link. - should == "http://blog.id774.net/post/2014/10/01/532/" - subject.instance_variable_get(:@pipeline)[0].items[0].title. - should == "dummy title" - subject.instance_variable_get(:@pipeline)[0].items[0].description. - should == "aaa bbb ccc http://blog.id774.net/post/2014/10/01/532/ ddd eee" + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@pipeline)[0].items[0].link. + should == "http://blog.id774.net/post/2014/10/01/532/" + subject.instance_variable_get(:@pipeline)[0].items[0].title. + should == "dummy title" + subject.instance_variable_get(:@pipeline)[0].items[0].description. + should == "aaa bbb ccc http://blog.id774.net/post/2014/10/01/532/ ddd eee" + } + end end - end + end end diff --git a/spec/plugins/filter/sanitize_spec.rb b/spec/plugins/filter/sanitize_spec.rb index 6248bfe..189e15b 100644 --- a/spec/plugins/filter/sanitize_spec.rb +++ b/spec/plugins/filter/sanitize_spec.rb @@ -10,146 +10,151 @@ require File.expand_path(File.dirname(__FILE__) + '../../../spec_helper') -require 'filter/sanitize' - -describe Automatic::Plugin::FilterSanitize do - context "It should be sanitized" do - subject { - Automatic::Plugin::FilterSanitize.new( - {}, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge", - "fuga", - "Mon, 07 Mar 2011 15:54:11 +0900" +# FilterSanitize needs the sanitize gem, which the Gemfile declares in its +# optional :plugins group. The default suite and CI do not install it, so this +# spec runs only where the operator has. See doc/POLICY.md section 5. +if AutomaticSpec.optional_dependency?('sanitize') + require 'filter/sanitize' + + describe Automatic::Plugin::FilterSanitize do + context "It should be sanitized" do + subject { + Automatic::Plugin::FilterSanitize.new( + {}, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge", + "fuga", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == 'fuga' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == 'fuga' + } + end end - end - context "It should not be sanitized in basic mode" do - subject { - Automatic::Plugin::FilterSanitize.new( - { - 'mode' => "basic" - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge", - "fuga", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should not be sanitized in basic mode" do + subject { + Automatic::Plugin::FilterSanitize.new( + { + 'mode' => "basic" + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge", + "fuga", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == 'fuga' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == 'fuga' + } + end end - end - context "It should not be sanitized in restricted mode" do - subject { - Automatic::Plugin::FilterSanitize.new( - { - 'mode' => "restricted" - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge", - "fuga", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should not be sanitized in restricted mode" do + subject { + Automatic::Plugin::FilterSanitize.new( + { + 'mode' => "restricted" + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge", + "fuga", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == 'fuga' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == 'fuga' + } + end end - end - context "It should not be sanitized in relaxed mode" do - subject { - Automatic::Plugin::FilterSanitize.new( - { - 'mode' => "relaxed" - }, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge", - "fuga", - "Mon, 07 Mar 2011 15:54:11 +0900" + context "It should not be sanitized in relaxed mode" do + subject { + Automatic::Plugin::FilterSanitize.new( + { + 'mode' => "relaxed" + }, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge", + "fuga", + "Mon, 07 Mar 2011 15:54:11 +0900" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == 'fuga' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == 'fuga' + } + end end - end - context "It should be sanitized" do - subject { - Automatic::Plugin::FilterSanitize.new( - {}, - AutomaticSpec.generate_pipeline { - feed { - item "http://testsite.org", "hoge" + context "It should be sanitized" do + subject { + Automatic::Plugin::FilterSanitize.new( + {}, + AutomaticSpec.generate_pipeline { + feed { + item "http://testsite.org", "hoge" + } } - } - ) - } - - describe "#run" do - its(:run) { should have(1).feeds } - - specify { - subject.run - subject.instance_variable_get(:@return_feeds)[0].items. - count.should == 1 - subject.instance_variable_get(:@return_feeds)[0].items[0].description. - should == '' + ) } + + describe "#run" do + its(:run) { should have(1).feeds } + + specify { + subject.run + subject.instance_variable_get(:@return_feeds)[0].items. + count.should == 1 + subject.instance_variable_get(:@return_feeds)[0].items[0].description. + should == '' + } + end end - end + end end diff --git a/spec/plugins/subscription/g_guide_spec.rb b/spec/plugins/subscription/g_guide_spec.rb index 5d63ca4..5add3cd 100644 --- a/spec/plugins/subscription/g_guide_spec.rb +++ b/spec/plugins/subscription/g_guide_spec.rb @@ -31,7 +31,7 @@ def g_guide(config = {}, pipeline = []) subject { g_guide(config) } it 'feed_url' do - subject.feed_url(config['keyword']).should == URI::Parser.new.escape( + subject.feed_url(config['keyword']).should == URI::RFC2396_Parser.new.escape( Automatic::Plugin::SubscriptionGGuide::G_GUIDE_RSS + "condition.keyword=#{config['keyword']}&" + 'stationPlatformId=0&') @@ -42,7 +42,7 @@ def g_guide(config = {}, pipeline = []) subject { g_guide(config) } its(:feed_url) { - should == URI::Parser.new.escape( + should == URI::RFC2396_Parser.new.escape( Automatic::Plugin::SubscriptionGGuide::G_GUIDE_RSS + 'stationPlatformId=1&') } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ec648a3..c6eed05 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -83,13 +83,23 @@ module AutomaticSpec FileUtils.remove_entry(TEST_HOME) if File.directory?(TEST_HOME) end + # Gems the Gemfile declares in its optional :plugins group. They are not + # runtime dependencies of the framework, `bundle install` does not install + # them, and the default suite therefore does not verify the plugins that need + # them. Which plugin needs which is in doc/PLUGINS.md section 6. + # + # This is a declared list rather than something inferred from a load failure: + # what the default suite does not cover is decided here, in one place, and a + # spec that names a gem absent from this list is a mistake and says so. + OPTIONAL_PLUGIN_GEMS = %w[nkf sanitize].freeze + class << self # Load a plugin, or report that its dependency is absent. # - # A plugin whose gem is not installed -- because the gem is optional, or - # because the service it talks to no longer exists -- is never stubbed into - # passing (doc/POLICY.md Invariant 7). Its spec is skipped instead, and the - # reason is printed, which is the honest signal. + # A plugin whose gem is not installed -- because the service it talks to no + # longer exists, and no currently published gem speaks to it -- is never + # stubbed into passing (doc/POLICY.md Invariant 7). Its spec is skipped + # instead, and the reason is printed, which is the honest signal. def plugin_available?(path) require path true @@ -99,10 +109,40 @@ def plugin_available?(path) false end + # Whether an optional plugin gem is in this bundle. A spec whose plugin + # needs one guards its whole file with this, because the plugin's own + # `require` runs when the file loads. + # + # if AutomaticSpec.optional_dependency?('sanitize') + # require 'filter/sanitize' + # describe ... do ... end + # end + # + # Installing the group is what runs these: + # + # BUNDLE_WITH=plugins bundle install + def optional_dependency?(gem_name) + unless OPTIONAL_PLUGIN_GEMS.include?(gem_name) + raise ArgumentError, + "#{gem_name} is not one of the optional plugin gems the Gemfile declares" + end + + return true if Gem::Specification.find_all_by_name(gem_name).any? + + skipped_optional << gem_name + warn "[automatic] not verified by this run: the optional plugin gem " \ + "#{gem_name} is not installed (BUNDLE_WITH=plugins bundle install)" + false + end + def skipped_plugins @skipped_plugins ||= [] end + def skipped_optional + @skipped_optional ||= [] + end + def generate_pipeline(&block) generator = StubPipelineGenerator.new generator.instance_eval(&block)