This document decides how this repository is implemented: the coding rules, the responsibilities of the parts and the direction of dependency between them, the handling of settings and credentials, the approach to tests and documentation, the versioning scheme, and the criteria a change is judged by.
This document stands on its own. It is the whole implementation policy of this repository, and no rule here is completed by a document kept somewhere else. A subject it does not cover is a gap in this document, to be filled here rather than looked up elsewhere.
What the system is for belongs to REQUIREMENTS.md. How it
is composed belongs to BASIC_DESIGN.md. The Recipe format
and the plugin contract belong to PLUGINS.md. This document does
not restate them. It decides how they are carried out.
The Invariants below decide over the rest of it.
Simplicity is robustness.
This is the governing principle for every rule below. It does not authorize dropping required behavior, compatibility, security, safety, documentation, or validation. It requires satisfying those requirements with no more complexity than the actual problem justifies.
Complexity is itself a source of failure, compatibility risk, operational risk, security risk, and maintenance cost. Do not add a branch, state variable, helper, abstraction, dependency, network boundary, retry, fallback, validation, configuration state, or framework unless the specification requires it or it addresses a realistic failure mode with meaningful consequence.
Do not re-check a condition already guaranteed by a preceding successful operation or an established invariant. Do not create a layer merely to resemble a possible future architecture.
When two designs satisfy the same requirements, choose the one with less control flow, less state, fewer dependencies, fewer side effects, fewer moving parts, and fewer operational assumptions. Simplicity is judged by the number of necessary concepts and behaviors, not by line count alone.
Automatic Ruby is neither system infrastructure nor a purpose-built application. It is a general-purpose composition framework. Its purpose is to keep a small composition mechanism stable enough that independent plugins can be combined freely, without turning the framework into a host baseline, a general workflow engine or a collection of hard-coded applications.
- Automatic Ruby is a framework, and the framework is the small part. It loads a Recipe, finds classes by name and calls them in order. Nearly every change belongs in a plugin, and a change that adds domain knowledge to the framework needs a reason beyond convenience.
- Generality comes from composition, not from accumulating framework features. A concrete use case that needs branching, transactional orchestration, multi-user state, permissions, a strongly coupled domain model or another responsibility that does not fit a short linear pipeline is not automatically a reason to enlarge the framework. A purpose-built application is often the correct boundary for that complexity.
- It is one person's tooling, run unattended from
cron. It is not a service, not multi-tenant and not a product. That premise decides several rules below that would otherwise look lax — the Recipe being trusted, chiefly. - This document applies to everything committed here: the library, the plugins, the executable, the tests, the packaging and the documents.
- Ruby is the implementation language and stays the implementation language. A proposal to reimplement the core in another language is out of scope, and so is turning this into a Rails application.
These come before every other rule here. Some of what they forbid is what a general policy would otherwise ask for.
- The Recipe format and the plugin contract are interfaces, and this
repository is not free to change them. Both are depended on by files this
repository cannot see: Recipes in
~/.automatic/config, plugins in~/.automatic/plugins. A change that breaks either is deliberate, is justified, and is recorded inVERSIONS. - Preserve the pipeline contract. Its data representation and composition
semantics are defined in
PLUGINS.md, not independently in this policy. Do not change that contract as an incidental implementation decision. - The plugin architecture is not replaced. Recipes are not superseded by hard-coded procedures, the loader is not replaced by a registry that must be edited to add a plugin, and the framework does not become a single application with the plugins folded into it.
- A dependency needed by one plugin is not a dependency of the framework. Installing the gem must not pull in an SDK for a service the operator does not use. See section 9.
- Composability is an architectural invariant, not an optimization goal. The small framework, independent plugins, one pipeline representation and Recipe-level ordering are the identity of the system. They are not traded away merely for convenience, central validation, richer orchestration or a more application-like design.
- A failure is never silent. No
rescuethat returns an empty pipeline as if nothing happened, and no run that exits zero after something went wrong. - The default test suite reaches no network and needs no credential. None is configured in CI.
- A plugin that cannot work is never simulated into working. Stubbing a dead service to make a test pass, or to make the catalogue look better, is forbidden outright. Removing the plugin is the correct outcome. See section 4.
- No credential is committed, in a Recipe, an example, a fixture or a test, and none is written to the log.
- Historical release history is not rewritten. Past versions and their
dates in
VERSIONSare a record, not a thing to tidy. - Follow the authoritative license documents. Apply the terms defined
in
LICENSE.mdto new repository-owned files, and preserve any separate terms governing third-party material.
The repository is maintained in three layers, and they do not receive the same degree of conservatism.
-
Core contracts receive the strongest compatibility protection: the Recipe format, plugin contract, pipeline shape, plugin lookup and override semantics, execution order and established CLI contract.
-
Framework implementation may improve within those contracts: the CLI, loader, pipeline implementation, logging and shared helpers are not frozen merely because they are old.
-
Plugins are replaceable components: they may be added, repaired, replaced or removed as their external systems and purposes change. A dead integration is not retained by simulation.
-
Simple comes before clever. Common work should fit in a short Recipe, and a new abstraction needs a concrete problem the existing plugin contract cannot solve.
-
Documentation examples must run, and the Quick Start uses Supported plugins only. Supported paths receive maintenance priority over integrations that need rework.
-
Prefer the smallest change that solves the problem. A pipeline that has run in someone's
cronfor a decade has earned the benefit of the doubt. -
Understand why something exists before replacing it. A constant that looks arbitrary usually encodes an operational fact.
-
A capability that can live in a plugin lives in a plugin.
-
A comment preserves a reason, constraint, non-obvious intent, or decision that a later change could otherwise undo. Do not use comments merely to restate code whose operation is already evident.
-
Old is not a defect. Neither is unfashionable. A defect is something that does not work, is not understood, or cannot be tested.
-
Use
must,always, andneveronly for invariants that admit no reasonable exception. Express preferences and situational decisions withprefer,should, or other qualified wording.Do not apply a rule literally where doing so would defeat the repository's stated purpose, intended behavior, compatibility, safety, or approved change scope.
Where the applicable specifications and policy leave a legitimate choice open, an explicit maintainer decision governs that choice. General conventions do not override it.
Automatic Ruby does not inherit every maintenance rule that is appropriate for long-lived host infrastructure. Its core contracts deserve comparable caution because external Recipes and plugins depend on them, but the whole repository is not treated as immutable infrastructure.
Documentation work, policy work, review, diagnostics, cleanup, or another task whose stated scope does not include an implementation change must not expand into one merely because an improvement opportunity is discovered.
Before changing established behavior, weigh the probability and impact of the failure being prevented against the added complexity, new failure modes, compatibility risk, operational cost, maintenance cost, and rollback cost.
A safety-motivated change is not automatically justified by Safety having priority over Efficiency. Added complexity can itself create new failure modes, compatibility risk, operational risk, and maintenance cost. Evaluate the proposed change as a whole.
Behavior that has operated reliably over time is evidence and has value of its own. Do not refactor established infrastructure merely because another design appears cleaner, more modern, or more defensive.
An implementation change is made only when that change has been explicitly chosen as part of the work being performed. Before deployment, validate it in the supported and representative environments affected by the change. A successful check in one convenient environment is not sufficient evidence for a change whose compatibility risk extends to other supported environments.
Established behaviour is evaluated by layer. A Recipe or plugin contract that has been used for years has strong compatibility value. A framework internal implementation detail may be improved while preserving those contracts. A shipped plugin may be replaced or removed when its external service or interface is gone and the plugin can no longer perform real work.
Safety mechanisms are means, not goals. A guard, validation layer, sandbox, permission model, retry layer or other defensive mechanism is justified by the risk it materially reduces and by whether that benefit outweighs added complexity, new failure modes and maintenance cost. This project is trusted single-user tooling. Controls appropriate to an untrusted multi-user service are not added merely because they are generally considered safer.
Long-running operational history is evidence, especially for Recipe semantics, plugin contracts and cron pipelines. It is not a reason to preserve a broken integration or to freeze internal implementation that can change without altering those contracts.
A change that would introduce branching, loops, fan-out, transactional orchestration, multi-user state, a central plugin registry, domain knowledge in the framework or another responsibility that changes the composition model is an architecture change. It is not reached as a routine refactoring or as a convenient way to satisfy one plugin or one Recipe.
bin/automatic
|
v
lib/automatic/cli.rb
|
v
lib/automatic.rb -> recipe.rb -> pipeline.rb
| |
| v
| Automatic::Plugin::*
v |
log.rb feed_maker.rb feed_parser.rb http.rb
^----------------------------------+
Dependency points one way and there is no edge back up:
bin/automaticknows only the CLI. It putslibon the load path, requiresautomatic/cli, and exits with the status it is given. No option, no subcommand and no policy lives there.- The CLI knows the framework, but the framework does not know the CLI.
Nothing under
lib/automatic/other thancli.rbmay reference it. Pipelineknows how to find and call a plugin, but it knows no plugin. A reference to a plugin class name in the framework is a design error.- A plugin knows
Log,FeedMaker,FeedParser,Httpand its own libraries. It does not know another plugin, does not know the CLI, and does not reach intoAutomaticfor directories other than through the helpers provided. Log,FeedMaker,FeedParserandHttpare leaves. They depend on nothing else in this repository.- A new responsibility goes to the part that owns it. Where it appears to belong to two, the boundary is wrong and is corrected, rather than the code being written across it.
- The framework's knowledge of a plugin is: its name, its file's location, its
constructor's two arguments and its
runmethod. It does not know a plugin's category semantics, its settings, its dependencies or its failure modes. - The framework does not validate a plugin's
config. It cannot know what is valid, and pretending otherwise would put plugin knowledge in the framework. - The framework does not inspect the pipeline between plugins.
- A plugin does not modify framework state. It does not set
Automatic.root_dirorAutomatic.user_dir, and it does not change the log level. - Plugin-to-plugin dependency is avoided. A plugin that wants another plugin's result is a Recipe with two entries in it.
- Shared plugin code that the framework does not use stays under
plugins/, not inlib/.plugins/store/database.rbis where it is for that reason.
- A Recipe is the whole of a job's configuration. There is no second file, no configuration directory, and no environment-variable settings.
- The one exception is
AUTOMATIC_RUBY_ENV=test, which permits the user directory to be overridden. It exists for the tests and is not an operator interface. - The framework reads exactly one
globalkey,global.log.level. Adding a second is a change to the Recipe format and is judged as one. global.timezoneandglobal.cacheare inert and stay inert. They are not removed — that would edit operators' files for no gain — and they are not given a meaning.- A plugin reads its settings from
@configby string key, toleratesnilfor the mapping and for any key, and follows the established names:retry,interval,db,path. - Not every constant becomes a setting. A value the operator would plausibly change is a setting. A value that is part of what the plugin means stays in the code.
- The framework catches nothing from a plugin. A plugin that raises ends the
run, for the reason given in
REQUIREMENTS.mdsection 12. Adding a blanket rescue around plugin execution would change what every existing Recipe means and is not done. - A plugin owns its transient failures, through
retryandinterval, and logs each failed attempt. - The framework's own failures have named classes in
lib/automatic.rb:Automatic::Errorand, under it,NoRecipeError,NoPluginErrorandInvalidRecipeError. A new framework failure gets a class rather than aRuntimeErrorwith a message. rescuewithout a class isrescue StandardErrorand that is what is meant. A barerescueis acceptable in a plugin's retry block, where the point is that any failure of the attempt is retried, and unacceptable elsewhere. Where used, it logs.- An unexpected exception is left to propagate out of the CLI. It is a defect and its backtrace is wanted.
- The library never calls
exitorabort. Exit status is decided at the process entry point, from the valueAutomatic::CLI.runreturns. - The result of an operation, whether later work continues, and whether a message is emitted are separate decisions. This does not change the framework rule above: a plugin exception still ends the run, and plugin retry behavior remains owned by the plugin.
- A required condition whose absence makes correct completion impossible is a failure, not a warning used to keep the run moving. A normal guard, no-op, or inapplicable path is not a failure merely because it performs no work.
- A library file never calls
puts,printorwarn. It logs, throughAutomatic::Log. - The CLI writes diagnostics to standard error and requested output — help, version, subcommand results — to standard output.
- A plugin whose purpose is to write to the terminal holds its output object in
an instance variable defaulting to
$stdout, so that a test can substitute it. inforecords useful normal progress.warnis reserved for a degraded or otherwise abnormal but recoverable condition the operator should know about. A normal guard, no-op, or inapplicable path may be silent and is not a warning merely because work was skipped.errorsays what failed. An error that was rescued is logged atwarnorerror, never atinfo.- Do not emit a line merely to prove that a normal branch was taken, and do not duplicate a failure message at multiple layers when one responsible layer already reports it through the established interface.
- No log line contains a credential. A plugin that logs its own settings wholesale is a defect.
- The framework touches two roots: the installation directory and
~/.automatic. Nothing else, and no absolute path elsewhere appears in the code. - Paths are built with
File.joinandFile.expand_path, never by string concatenation. - The user directory takes precedence over the installation, for plugins, Recipes, databases and assets alike. Where a part of it is absent, the installation's own directory is the fallback.
scaffoldcreates and never overwrites.unscaffoldremoves the whole user directory and is the one destructive operation the framework offers. It is not extended, and nothing else acquires the ability to delete an operator's data.- A plugin writes only where its settings tell it to.
- The framework reaches nothing. No update check, no telemetry, no phone-home. Every request is a plugin's, on a Recipe's instruction.
- HTTPS is used wherever the service offers it. A plugin that still uses plain
HTTP is a defect to be recorded, and one that sends a credential over plain
HTTP is recorded as such in
PLUGINS.md. - TLS certificate verification is never disabled. There is no acceptable reason, and a plugin that did it has been corrected.
- A plugin that fetches repeatedly from one host supports
intervaland its documentation says to set it. Scraping politely is a requirement, not a courtesy. - A URL that comes from a setting or from feed content is escaped before use, and is never interpolated into a shell command. An external command is run as an argument vector.
- A URL that comes from feed content is external input. It is fetched
through
Automatic::Http, which restricts the scheme to HTTP and HTTPS, becauseURI.openon such a string will read a local file as readily as an article. - Every request has a connect and a read timeout. An unattended run that hangs is a failure mode with no upper bound on its cost.
- A Recipe is trusted local configuration, equivalent to a shell script the
operator wrote. This is the trust boundary, it is stated in
REQUIREMENTS.mdsection 17, and it is the premise the following rules assume. - Even so, YAML is loaded safely, so that a Recipe cannot name a Ruby class to instantiate. This costs nothing, since no Recipe needs it, and it removes a second and avoidable path to code execution. Do not present this as making an untrusted Recipe safe. The loader is not the boundary.
- Credentials are Recipe settings. That makes a Recipe holding them a secret file, which the documentation says plainly rather than glossing over.
- No credential in the repository: not in an example Recipe, not in a fixture,
not in a test, not in a comment. The example Recipes in
config/need none. - No credential in the log, in an exception message, or in a pipeline item.
- No credential in CI. A test that needs one is not part of the default suite.
- A change that touches authentication says in its
VERSIONSentry what it changed.
Before accepting a change, ask whether it is the smallest coherent change that serves its purpose, whether it alters behavior outside that purpose, whether it adds an unnecessary dependency or side effect, whether it exposes private information, and whether directly affected documentation remains correct.
- Does it keep the Recipe format and the plugin contract, and if not, is that deliberate and recorded?
- Does it respect the direction of dependency?
- Does it leave the framework the small part?
- Does it keep a plugin's dependency out of everyone else's installation?
- Does a test say it works, and does the default suite still need no network?
- Do the documents still match the code, in the same commit?
- Which layer is changing: a core contract, framework implementation or a replaceable plugin?
- Is a framework change solving a framework problem, or absorbing domain-specific behaviour that belongs in a plugin or an application?
- Does a new dependency remain local to the plugin that needs it?
- Does a proposed safety mechanism materially reduce a relevant risk without imposing disproportionate complexity on trusted single-user tooling?
- Does the change preserve the small linear composition model, and if not, has an explicit architecture change actually been chosen?
A pull request carries one coherent higher-level purpose, which may require coordinated changes across several files or responsibilities.
Do not split one approved purpose mechanically by individual finding, file, or review comment, and do not include unrelated cleanup, formatting, or feature work.
Represent one coherent change as one commit and amend review corrections instead of accumulating follow-up commits.
Rewriting the history of a branch under review requires an explicit
instruction from the maintainer. When such a rewrite is authorized, use
--force-with-lease and limit the operation to the branch under review.
Check the final branch against its base to remove abandoned intermediate wording and work. Merge only with explicit maintainer approval.
Unless explicitly instructed otherwise by the maintainer, an automated agent must commit authorized changes to a work branch and submit them through a pull request targeting the approved base branch.
An automated agent must not commit or push directly to master without
explicit instructions from the maintainer authorizing that operation.
Creating or updating a pull request does not authorize the agent to merge it or enable auto-merge. Either action requires an explicit instruction from the maintainer.
Authorization for one operation does not imply authorization for another. These rules do not change a human maintainer's authority.
The prevailing style of this repository is what a change matches. It is not current fashion and that is deliberate: a change written in a different style makes a diff harder to read than the style saves.
-
Two-space indentation. No tabs. No trailing whitespace. A newline at end of file.
-
Single quotes for a plain string, double quotes when interpolating.
-
snake_casefor methods and variables,CamelCasefor classes,SCREAMING_ SNAKE_CASEfor constants. -
Braces for blocks are the established idiom here, including multi-line blocks, and were chosen to avoid stacked
ends. Existing code is left as it is. New code may use either, and matching the surrounding file matters more than the choice. -
Lines around 100 columns. Not enforced, and not a reason to reformat.
-
Prefer a guard clause to a wrapping
unless, in new code. -
Write English explanatory prose in clear, natural sentences. Do not replace a sentence with a label and a colon, a status prefix such as
Implement:orIncompatible:, a symbol, or an unexplained abbreviation.Apply this to English prose in comments, docstrings, documentation, version histories, release notes, commit messages, and pull request descriptions.
This rule does not alter the established forms of headings, list labels, tables, code, configuration syntax, machine-readable fields, literal interface strings, or non-English content required by the repository's purpose.
Wrap ordinary prose at approximately 80 columns where practical. Preserve the logical structure of paragraphs, lists, and other document elements. Do not break URLs, code, tables, identifiers, or other elements whose structure requires a longer line.
Shared boilerplate must retain its common wording, line breaks, blank lines, and formatting. Do not reformat it independently in individual repositories. Its established common form takes precedence over the ordinary line-wrapping guideline.
Do not rewrite historical entries or unrelated text merely to align their style.
- Do not reformat a file you are not otherwise changing. A large diff hides the change inside it.
- Do not rename a class, a method or a setting because the name is dated. The names in this repository are domain vocabulary: Recipe, pipeline, plugin, subscription, publish. They stay.
- Do not restructure a plugin while fixing it. A compatibility fix and a rewrite are two changes.
- Where a file is being substantially changed anyway, bringing it to the current style is fine.
Every Ruby file carries a header comment. Values start in the same column,
using Source Code::, the longest label, as the alignment baseline. Fields
appear in the order shown below. An executable uses this canonical form:
#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
# Name:: automatic
# Author: id774 (More info: https://id774.net)
# Source Code:: https://github.com/id774/automaticruby
# License:: The GPL version 3, or LGPL version 3 (Dual License).
# Contact:: idnanashi@gmail.com
# Created:: Feb 18, 2012
# Updated:: Aug 14, 2026
# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers.- Only files directly invoked as executables carry the shebang. Libraries, plugins and specs omit the shebang and otherwise use the same form.
Nameis the fully qualified name of what the file defines.Authorrecords the author or authors established by the file and its Git history. The former name774is normalized toid774. Third-party authors are never removed or replaced, and multiple authors retain their recorded order. Names and URLs are not inferred when the history does not provide them.- The labels are exactly
Name::,Author:,Source Code::,License::,Contact::,Created::,Updated::andCopyright::. Createdis never changed.Updatedis set to the date of a change that affects behaviour. A documentation-only or formatting-only change does not touch it.- The project-level copyright notice is
Copyright (c) 2012-2026 Automatic Ruby Developers.Explicit third-party copyright notices are preserved and are not inferred from theAuthorfield. - The
Licenseline records the GPLv3 or LGPLv3 dual license and matchesLICENSE.md. - A
Description::line may be added belowNamewhen the file's purpose is not obvious from its name. New plugins should have one. - The magic encoding comment is redundant on the supported Rubies. It is left in place in existing files, because removing it across existing files is a diff with no benefit, and it is not required in a new file.
Ruby files do not carry a per-file version history. Ruby source is versioned at the repository level. See Section 10.
- The supported range is stated in one place,
automatic.gemspec(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#escapebecoming obsolete is answered by namingURI::RFC2396_Parser, which means the same thing on all of them — not by aRUBY_VERSIONbranch. - A
RUBY_VERSIONconditional 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_VERSIONcomparison 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 for a decade are all deleted. - Nothing is written against a feature newer than the floor.
- A method removed by Ruby is replaced by its supported equivalent, and that is
a compatibility fix rather than a refactor:
Kernel#openon a URL becomesURI.open,File.exists?becomesFile.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.
- The framework requires only what the framework uses. A plugin's library never
appears in
lib/. - A plugin requires its libraries at the top of its own file.
- A library needed only by an optional path is required inside that path, so the
plugin loads without it. The S3 branch of
StoreFileis the example. - The CLI requires a subcommand's libraries inside that subcommand, so a
command that does not use
feedbagor the OPML parser does not load them. - An optional gem is required through
Automatic.require_optional, which names the gem, what needed it and how to install it when it is absent. A barerequireof an optional gem answers a solvable problem withcannot load such file, which is not the framework's best answer. Bundler.requireis not how the framework loads its dependencies. An installed library must not impose a bundle on the program requiring it. The Bundler setup inenvironment.rbis a convenience for a source checkout and does nothing when there is noGemfile.
- A new plugin follows
PLUGINS.mdsection 3, which is the contract, and section 3.10, which says which category it belongs in. - Converting the pipeline into some other representation is a
Publishplugin's work, and the result stays inside the plugin. It is serialized at the boundary, written to the destination, and never passed along the pipeline or made known to the framework, which keeps Invariant 2 and section 1.6 intact. Producing a portable document — one a person can read, ordinary tools can process and another program can be given, with no service behind it — is a destination like any other and belongs in that category. - A new plugin is added to the catalogue in
PLUGINS.mdsection 6 in the same change, with its settings table and its status. - A new plugin comes with a spec that reaches no network.
- A plugin's dependency is not added to the gemspec's runtime dependencies. See section 9.
- A change to an existing plugin does not change what an existing Recipe means unless that is the point of the change.
Plugins outlive the services they talk to. The policy for what happens then:
- A shipped plugin has a current practical use. That is the condition for being in the gem, and it is a condition that has to keep being met, not one met once. Having been useful is not the test.
- A plugin is classified, in
PLUGINS.mdsection 6, as Supported, Supported (external) or Needs rework, with the reason. There is no status meaning "does not work and never will". A plugin in that position is removed. - A dead integration is never faked into life. No stub of a shut-down service, no mock that makes an integration look alive, no test that asserts against a simulation. This is Invariant 8 and it has no exceptions. If a plugin can only be made to look supported by simulating what it talks to, what it needs is deletion, not a double.
- Unsupported code is not kept for preservation. Git history holds every implementation this project ever shipped, and holds it without installing it on anyone's machine or listing it in a catalogue an operator reads for guidance. A plugin retained only so that its code exists somewhere is retained for a reason the version control system already covers.
- The judgement is evidenced. "Nobody uses that any more" is not a reason. The service's own site, its API documentation or its published shutdown notice is, and the reason goes in the catalogue entry or in the removal record. Where the evidence cannot be obtained, the plugin is classified Needs rework and kept: the failure mode of guessing is deleting something that works.
- Needs rework means restoration is realistic. A capability the service still offers, reachable by a current API, with the work amounting to a migration rather than a new project. A plugin whose service is gone is not Needs rework. It is removed.
- Restoring a plugin to a service's current API is a separate change, one plugin at a time, with the catalogue entry updated in the same commit. Where the current API forces a credential format the old Recipe cannot express, that is a breaking change and is documented as one rather than hidden behind a translation.
- Removing a plugin removes all of it: implementation, specs, example and
integration Recipes, catalogue entry, and any optional dependency nothing
else needs. A class left behind to raise "this no longer works" is not a
courtesy. The loader's
NoPluginErrorsays the same thing earlier and without shipping code. - A removal is recorded in
VERSIONSin enough detail that an operator whose Recipe breaks can find out why, and the reasons are kept inPLUGINS.md. Removals are batched into a release rather than trickled, so that an upgrade has one list to read. - A modernized plugin keeps its Recipe's meaning. Class names, setting names and defaults are not changed for tidiness. Where a fix makes a plugin behave as its documentation always said it did, that is still a behaviour change and the catalogue entry says so.
- A Supported plugin has deterministic local tests. They cover this side of the boundary — settings, request construction, serialization, response handling, error behaviour — and reach no network, need no credential and require no running service. The availability of somebody else's API is not something a unit test can assert and is not something required CI waits on.
- RSpec, under
spec/, mirroring the source tree:spec/lib/for the framework andspec/plugins/<category>/for plugins. - The default suite reaches no network and needs no credential. This is
Invariant 7. A spec that would is not written. The integration Recipes under
test/integration/are where that belongs, and they are run by hand. - A framework spec covers the loader, the Recipe, the pipeline, the log and the CLI's exit statuses.
- A plugin spec constructs the plugin with a
configand a pipeline built byAutomaticSpec.generate_pipeline, callsrun, and asserts on the result. - An example that reaches a real host is tagged
:networkand is excluded from the default suite and from CI. It is kept, because it is a real test and is the way to check a plugin against the service it talks to, and it is run deliberately withAUTOMATIC_NETWORK_SPECS=1. Several of these point at hosts 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
Gemfiledeclares in an optional group is not installed bybundle install, so the plugins that need it are not verified by the default suite or by the required workflow. That is a decision, taken here, and not something a failure discovers: the gems it applies to are the declared listAutomaticSpec::OPTIONAL_PLUGIN_GEMS, a spec whose plugin needs one guards its file withAutomaticSpec.optional_dependency?, and naming a gem that is not on the list raises rather than skipping. Selecting the group withbundle config set --local with pluginsand installing runs those specs as part of the ordinary suite. - A guard is a skip with a reason, not a
pending. A spec outside the default suite prints why it is outside it and does not run. The default suite's output is a list of what was verified rather than a list of what was not. - 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-errorand 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
HOMEto a temporary directory rather than operating on the developer's real~/.automatic. - Coverage is measured with SimpleCov when
COVERAGE=on, and is not a gate. The historicalrcovandsimplecov-rcovtooling has been removed. It existed for Ruby 1.8 and for a CI server that no longer runs. rake specruns the suite, andrake spec:libandrake spec:pluginsrun the halves.rakealone runsspec.- CI runs
bundle installandrake specon every supported Ruby version, and nothing that needs a secret.
- The CLI is a library,
Automatic::CLI, andbin/automaticis a process entry point that does nothing but call it and exit with its result. CLI.runreturns anIntegerand never callsexit, so the command line is unit-testable.- Exit statuses are fixed:
0did the work or printed help or version,1failed or nothing was asked for,2the command line was rejected. --helpand--versionexit0and print to standard output.- Option parsing uses
optparsefrom the standard library. No CLI framework is introduced. The interface is one option and seven subcommands, and a gem for that would be a dependency for nothing. - The subcommands and the meaning of
-care part of the compatibility promise. Adding a subcommand is fine, but removing or renaming one is a breaking change. - A subcommand prints its result to standard output, because printing is what it is for.
What this repository promises not to break, absent a deliberate and recorded decision:
| Interface | Promise |
|---|---|
| Recipe format | A Recipe that worked keeps working |
| Plugin contract | new(config, pipeline) and run |
| Plugin naming | The class-name-to-file-path rule |
| User directory | ~/.automatic and its four subdirectories |
| User plugin precedence | The user directory shadows the installation |
| CLI | -c, the subcommand names, and the exit statuses |
| Store databases | Existing SQLite files stay readable |
| Gem name | automatic, providing the automatic executable |
Breaking one of these is a decision, taken deliberately, recorded in
VERSIONS in terms an operator can act on, and never a side effect
of a clean-up. Adding an optional setting whose default preserves current
behaviour breaks nothing.
-
Documentation is updated in the same change as the behaviour it describes. A behaviour change with no documentation change has not been finished.
-
A concrete specification has exactly one authoritative home appropriate to the responsibility that owns it. Other documents may link to or summarize that specification for navigation, but they do not create a competing normative copy.
-
The documents divide as follows:
Document Holds README.mdThe entry point: what it is, how to install and run it, where everything else is doc/REQUIREMENTS.mdWhat the system is for and what it guarantees doc/BASIC_DESIGN.mdHow it is composed doc/PLUGINS.mdThe Recipe format, the plugin contract, the plugin catalogue doc/POLICY.mdThis document: how a change is made and judged doc/DEPLOYMENT.mdInstalling, running and operating it doc/VERSIONSRelease history doc/LICENSE.md,doc/COPYING,doc/COPYING.LESSERThe licence doc/AUTHORSContributors -
The implementation is the primary evidence of what currently happens. It is not, by itself, proof that the current behavior is the intended specification.
When implementation and documentation disagree, compare the implementation, documented interface, component header, history, existing design, and maintenance intent. If the implementation contains a regression, do not adopt that regression as the specification merely because it is present in the current code.
-
Content adapted from an external article, answer, documentation example, source file, dataset, or other third-party material credits its source and respects the license or redistribution terms that apply to it.
-
No cross-repository reference. These documents never say "see the policy of another repository", or "as in X". Everything needed to understand, build, run and change this repository is here. Another repository may be a useful reference while working, and it is not a citation.
-
Documents are in English, as are code, comments and commit messages. Japanese appears only where it is data: a search keyword in an example, a broadcast station name in a plugin's settings.
-
Markdown documents may assume a renderer.
VERSIONS,COPYING,COPYING.LESSERandAUTHORSare plain text and keep their extensionless names, which are the names they are published and linked under. -
Prose wraps near the width the document already uses.
VERSIONSfollows section 10.4 instead. -
A document is not deleted for being old. It is deleted when its content has been moved somewhere that is now the source of truth, and the move is recorded in
VERSIONS.
Install the core by default, and install a plugin's dependencies only when they are needed. Dependencies fall into three groups, and which group a gem is in is a decision, not an accident:
Runtime dependencies — declared in automatic.gemspec, installed by gem install automatic. A gem is here only if a file in lib/ requires it: what
require 'automatic', loading a Recipe, loading a plugin, running a pipeline
and the command line itself need, and nothing more. A gem that reached this list
because of a plugin, because a plugin 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, declared in an optional group of the Gemfile, 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.
Dependency minimization is not a repository-wide contest to use the fewest libraries. A plugin may be rich when its purpose requires it. The rule is that the cost stays with the plugin that needs it: a parser, database driver, SDK or other domain-specific dependency must not become a framework dependency merely because one plugin uses it.
The permanent rules of the split:
- A core dependency is one the framework itself has. A plugin's dependency
is never a framework runtime dependency, however useful, popular or
Supported that plugin is. How many Recipes happen to use it is not the test.
What
lib/requires is. - The operator who uses the plugin installs its gem. Not having it must never stop the framework from starting, and a Recipe that does not name the plugin must run without it.
- A new plugin does not increase the core dependencies. Adding one to the runtime list needs an architectural justification — the framework itself came to need the gem — recorded with the change. Wanting the plugin to work out of the box is not one.
- The default tests and required CI do not depend on an optional integration. Being in this group has that intended consequence: those plugins are not part of what a green build guarantees. See section 5.
- An unsupported or optional integration does not decide a framework-wide dependency. Where one plugin needs a gem, that gem is the plugin's.
A missing optional gem is reported, not merely raised: the plugin requires it
through Automatic.require_optional, which names the gem, what needed it and
how to install it. That helper is the whole of the mechanism, and no dependency
manager, plugin manifest or resolver is introduced beyond it.
Development dependencies — the test and build tooling.
- A gem is added when something committed here uses it, and the commit says which plugin and why.
- Dependencies are not raised in bulk. Each is updated for a reason — a security fix, a Ruby compatibility requirement, an API this repository needs — and the code that uses it is checked against the new version. Bumping everything to latest and then repairing what broke is not how this is done.
- Version constraints are ranges wide enough not to conflict with the rest of an operator's bundle, and tight enough to exclude a major version this code has not been checked against.
- A gem no longer used by anything committed here is removed.
- A gem whose service no longer exists is removed, along with the plugin that
needed it. See section 4. An optional group left with nothing to install is
deleted from the
Gemfilein the same change. - Nothing is vendored. Dependencies come from RubyGems, which keeps their licences theirs.
- One gem source:
https://rubygems.org. - No plain-HTTP source, ever.
http://rubygems.orgin aGemfileis a defect, not a style question. - No source that has shut down.
gems.github.comhas not existed since 2014 and is not to reappear.
- The gemspec is hand-maintained. It was generated by Jeweler, which is no
longer maintained and which required its own Rake tasks and a
VERSIONfile to regenerate a file that is easier to simply edit. The generator is gone, and the file is now source. - The gemspec states
required_ruby_version, the licence, the homepage, the source code URL and the metadata links, and itsfileslist is derived from what Git tracks so that it cannot drift. - The
Gemfiledeclares the source and evaluates the gemspec, so that runtime dependencies are stated once. Development and optional gems are groups in theGemfile. Gemfile.lockis not committed. This is a library, and locking would impose a resolution on every consumer.Rakefilecarries the test tasks and nothing else. It is not a build system, and no build system is introduced.- A release requires green required CI. Its built gem is inspected and installed locally before publication, and its source version and metadata must agree.
- A published version is immutable. Credentials are never committed, and release commits and tags are not rewritten to conceal a publication error.
- The manual publication procedure is
RELEASING.md. Publishing is an explicit maintainer action, not a side effect of a build or test task.
Releases are numbered <year>.<month>, two digits each, taken from the release
date. This is the scheme the project has used since its first release in
February 2012, it mimics Ubuntu's, and it does not change.
26.08 a release made in August 2026
26.08.1 a release correcting 26.08, in the same month
A third <patch> level is appended only when a release corrects an earlier one
without accumulating a month's work. 14.12.1 and 14.12.2 are historical
examples.
The number carries no compatibility meaning. A month is not a major version, and
a compatibility break is signalled by what the VERSIONS entry says, not by the
number.
The release version appears in three places, which are changed together in one commit:
| Place | Form |
|---|---|
VERSION |
26.08 |
lib/automatic/version.rb |
Automatic::VERSION |
doc/VERSIONS |
The entry heading |
automatic.gemspec reads VERSION, and automatic --version prints
Automatic::VERSION, so neither is a separate place to update.
Historically lib/automatic/version.rb carried a -devel suffix between
releases while VERSION did not, and a spec asserted the exact string. That
divergence is not continued: the two agree, and the spec asserts that they
agree rather than asserting a literal.
- Work that is not released yet takes no version of its own. It belongs to
the entry already standing at the top of
doc/VERSIONS. - An unreleased entry carries
(Release Date: TBD). Replacing that with the date is the release itself, and is not a change to record inside the entry. - A repository that has not yet made its first release is in its initial
construction stage, and that stage takes no entry here. The work of building
up to the first release is not accumulated in
doc/VERSIONSone by one: the file is the record of released versions, not of the construction that precedes the first of them, and its first entry is written when that release is made. - A series of changes made on one day is one version, not several. Do not split a day's work across version numbers. This rule has no exception: separate commits, pull requests, independent features, bug fixes, security fixes, compatibility changes, breaking changes, or release units do not permit a second version number on the same calendar date. A same-date change joins that day's single entry. Independence decides bullet grouping only.
- If today's release has already been published, a correction may be prepared but no second version, tag or package release may be published until a later calendar date. A published version is never overwritten, reused or moved, and its correction is a later-dated version.
- Historical same-date versions remain recorded as historical facts.
14.10.0and14.10.1were both released on 2014-10-23 and both stay indoc/VERSIONS, but they violate the current one-version-per-day rule and are not precedent for future releases. - A documentation-only change takes no entry unless its scale makes it worth one line saying so.
- Git tags carry the release version, and are created only when a release is made.
doc/VERSIONS is a version-level summary of what each release changed. It is
not a transcription of the commit log: the commits record how the work happened,
the version history records what it amounts to.
-
Record externally meaningful or architecturally significant outcomes, such as compatibility, security, public interfaces, major features, packaging, licensing, documentation architecture and test strategy.
-
Omit development-time corrections, implementation details and intermediate states that were reverted or superseded before release. Git history retains that process.
-
Summarize related low-level changes under their substantive outcome instead of listing each method, dependency, test or file separately.
-
Each entry opens with
vX.YY (YYYY-MM-DD), orvX.YY (Release Date: TBD)while unreleased, underlined with-, followed by one-bullet per change. Newest first. UTF-8. -
Write one coherent change per bullet, at most two physical lines.
-
A single line at or under 80 columns is preferred whenever practical. Names and identifiers necessary to describe the change may pass 80 columns on the bullet's one or two lines when they cannot be shortened without losing meaning.
-
When a version history already exists, balance a new entry against the length of the existing lines, so that the whole file stays consistent. Holding to the form this file has settled on comes before the two-line, 80-column limit above, and leaves that limit standing wherever no such form exists.
-
When a bullet runs long, abstract it first. Drop the implementation detail, the example, the reason and the secondary effect, and state what the change is. Wrap onto the second line only when the abstracted bullet still exceeds 80 columns. Keep what a reader cannot reconstruct without it: what changed, what is now observably different, what it does to compatibility, what it does to security, and the identifiers someone would search for.
-
Changes serving one purpose are described together even when they touch several files. Related changes to one file within a version are normally one bullet. Changes to one file carrying independent meaning are not forced together — coherence decides, not the file name.
-
Entries touching the same file, plugin or feature are placed near each other, so that a version reads as a coherent whole. An independent change belonging with nothing already listed is appended to the end of the current entry.
-
Order within a version serves the reader, not the commit history.
-
Released entries retain their substantive history even when their wording or level of detail predates these rules.
-
doc/VERSIONScarries these guidelines again at its foot, and an entry written into it follows the limit recorded there. -
The first entry, at the lowest version
doc/VERSIONSreaches, reads onlyInitial release.and nothing else.
- Past entries and their dates are not rewritten, not to correct their wording, not to renumber them, and not to make them consistent with this document's current rules. They are the record of what was released.
- The release history was previously kept in
doc/ChangeLog. It has been moved intodoc/VERSIONSin full, anddoc/ChangeLogis gone. The two documents had the same responsibility and keeping both meant keeping two records that would diverge. No entry and no date was changed in the move. - Where the record is incomplete — releases with no tag, or a version bumped
with no entry —
VERSIONSsays so rather than inventing an entry.
The Ruby file-header convention in Section 2.3 remains unchanged. The following rules apply to the existing versioned shell build script, not to Ruby files that carry no individual version history.
These rules apply to individual source files that carry their own version history. Repository release versions are governed separately.
- Source file versions use a two-level
major.minorscheme. - When incrementing
minorwould reach10, roll over instead: incrementmajorby 1 and resetminorto0(for examplev0.9->v1.0,v1.9->v2.0,v2.9->v3.0). - Do not continue
minorpast9as in standard semantic versioning (do not usev1.10,v1.11, ...). - Raising
majorfor a reason other than the rollover is a decision the maintainer makes, not one this document derives from the change. - Removing or renaming an option, changing what an existing argument means,
changing a default so that an unchanged invocation does something else, and
changing how a path or a configuration value is resolved are all incompatible
changes. Say so in the
Version Historyentry, so that the number the change is released under can be chosen knowing that.
- CI runs on GitHub Actions.
.github/workflows/ci.ymlis the required check..github/workflows/plugins.ymlis a separate, non-required workflow that installs the optionalpluginsgroup and runs the same suite, so that the documented all-plugins setup is checked as well as described. - 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_versionare 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.mdsection 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.
- The required workflow installs no optional plugin gem, so no plugin's own dependency is a condition of the check that gates a change. The minimal configuration is what it runs, which is what keeps the split of section 9.1 honest over time. Where an optional integration is worth testing at all, it is tested separately from the required workflow. Section 5 says how.
- A non-required workflow is held to the same standard as the required one. It is separate so that an optional dependency cannot gate a change, not so that it may fail quietly.
- A failure is fixed, not silenced.
|| true,continue-on-errorand a step that hides its exit status are not how a build is made green. Narrowing what is guaranteed is a legitimate answer, but 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.