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)