Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@ site-owned content (your `Doc` registry, your pages, your themed
`application.tailwind.css`). Drop `--sync` to also (re)scaffold missing content
files — Thor prompts before overwriting anything that exists.

### Version-aware migrations

Every install and `--sync` stamps the docs-kit version it ran into your config
initializer, as an inert comment on the first line:

```ruby
# docs-kit synced: v1.0.5
# frozen_string_literal: true
Rails.application.config.to_prepare do
DocsKit.configure do |c|
# ...
```

On the next `--sync` after a `bundle update docs-kit`, the generator reads that
stamp, works out the gap to the newly-installed version, and runs the **ordered
release-to-release migrations** in between — renamed config knobs, changed route
shapes, restructured templates — in sequence, then restamps to the new version.
A site created before the stamp existed (no comment) is treated as the earliest
version, so it gets every migration. Migrations are **warn-only-safe** exactly
like the drift report: what a step can't safely automate it prints as a manual
checklist (`migration steps to apply by hand:`), never a destructive rewrite of
a line you've edited. There are no migrations to apply yet — the mechanism ships
ahead of the first release that needs one, so the upgrade path is already in
place.

### One-time cleanup for sites created before these landed

`--sync` detects drift it can't safely automate and prints a checklist — it
Expand Down
61 changes: 61 additions & 0 deletions lib/generators/docs_kit/install/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "yaml"
require "rails/generators/base"
require_relative "sync_report"
require_relative "migration_registry"
require_relative "../../../docs_kit/version"

module DocsKit
Expand Down Expand Up @@ -44,6 +45,18 @@ class InstallGenerator < ::Rails::Generators::Base
AGENTS_END = "<!-- END docs-kit -->"
AGENTS_BLOCK_RE = /#{Regexp.escape(AGENTS_BEGIN)}.*#{Regexp.escape(AGENTS_END)}/m

# The config initializer — the one file every site has, so it carries the
# last-synced version stamp. See stamp_synced_version / run_migrations.
INITIALIZER = "config/initializers/docs_kit.rb"

# The inert comment recording which docs-kit version last synced this site.
# A future `--sync` reads it to run only the ordered migrations between that
# version and the gem's current one. Absent → the site predates the stamp
# (treated as the earliest version, so every migration applies).
SYNCED_STAMP_RE = /^#\s*docs-kit synced:\s*v(\d+\.\d+\.\d+)\s*$/

def self.synced_stamp(version = DocsKit::VERSION) = "# docs-kit synced: v#{version}"

# The RuboCop wiring docs-kit injects. REQUIRE loads the cops;
# INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml).
RUBOCOP_REQUIRE = "docs_kit/rubocop"
Expand Down Expand Up @@ -308,6 +321,43 @@ def report_drift
report.items.each { |item| say " • #{item}" }
end

# Run the ordered release-to-release migrations between the site's
# last-synced version and the gem's current one — the payoff of the version
# stamp. `--sync` only (a full install is a fresh scaffold, not an upgrade),
# and BEFORE stamp_synced_version restamps, so it reads the OLD version. An
# un-stamped site is treated as the earliest version (every migration runs).
# Migrations are warn-only-safe: what they can't automate they hand back as
# a checklist, printed like the drift report. The registry ships EMPTY at
# 1.0.x, so today this is a no-op that establishes the upgrade path.
def run_migrations
return unless options[:sync]

warnings = MigrationRegistry.default.migrate!(synced_version, destination_root, self)
return if warnings.empty?

say_status :warn, "migration steps to apply by hand:", :yellow
warnings.each { |item| say " • #{item}" }
end

# Record which docs-kit version this site is now synced at, so the NEXT
# `--sync` can run only the migrations after it. Injected as an inert
# comment at the top of the initializer (the one file every site has) —
# which create_initializer never rewrites, so stamping is its own step.
# Idempotent: updates a stale stamp in place, adds one when absent, and
# is a no-op when already current.
def stamp_synced_version
path = File.join(destination_root, INITIALIZER)
return unless File.exist?(path)

current = self.class.synced_stamp
source = File.read(path)
updated = source.match?(SYNCED_STAMP_RE) ? source.sub(SYNCED_STAMP_RE, current) : "#{current}\n#{source}"
return if updated == source

File.write(path, updated)
say_status :update, "#{INITIALIZER} (synced v#{DocsKit::VERSION})", :green
end

def show_post_install
return show_sync_summary if options[:sync]

Expand All @@ -328,6 +378,17 @@ def show_post_install

private

# The docs-kit version this site was last synced at, read from the
# initializer's stamp. Un-stamped (a site created before the stamp landed,
# or a fresh skeleton) → "0.0.0", the earliest version, so every migration
# applies. Read BEFORE stamp_synced_version overwrites it.
def synced_version
path = File.join(destination_root, INITIALIZER)
return "0.0.0" unless File.exist?(path)

File.read(path)[SYNCED_STAMP_RE, 1] || "0.0.0"
end

def show_sync_summary
say_status :info, "docs-kit synced.", :green
say <<~MSG
Expand Down
32 changes: 32 additions & 0 deletions lib/generators/docs_kit/install/migration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module DocsKit
module Generators
# One ordered, versioned upgrade step between two docs-kit releases. `to` is
# the version the migration belongs to (the release that introduced the
# change); the registry runs it when a site's last-synced version is BELOW
# `to`. The block receives the site's `(destination_root, generator)` so it
# can read/rewrite files via the generator's helpers, and returns the list
# of manual-cleanup warnings it could NOT safely automate.
#
# Warn-only-safe by contract (the #24 drift pattern): a migration does what
# it can idempotently — never a destructive rewrite of a hand-edited line —
# and hands back strings for whatever needs a human. A `nil` return means
# "nothing to warn about".
class Migration
attr_reader :to, :description

def initialize(to:, description:, &block)
@to = Gem::Version.new(to.to_s)
@description = description
@block = block
end

# Run the transform against the site. Returns the (possibly empty) list of
# manual-cleanup warnings — never nil, so callers can flat-map safely.
def call(root, generator)
Array(@block.call(root, generator))
end
end
end
end
57 changes: 57 additions & 0 deletions lib/generators/docs_kit/install/migration_registry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require_relative "migration"
require_relative "../../../docs_kit/version"

module DocsKit
module Generators
# The ordered set of release-to-release migrations `--sync` applies. Given a
# site's last-synced version, it selects the migrations the site hasn't run
# yet — those in the half-open range `(from_version, upto]`: ABOVE the site
# version (a migration AT the site version is already applied) and no newer
# than the installed gem — and runs them in ascending order, collecting the
# warn-only messages each couldn't safely automate.
#
# The `upto` ceiling (default: the installed gem version) matters because
# `--sync` restamps the site to DocsKit::VERSION afterward. A migration whose
# `to` sat ABOVE the gem version would then still exceed the new stamp and
# re-run on EVERY sync forever. It can't legitimately exist anyway (a site
# can't have "arrived" at a release it doesn't have), so it's filtered out.
#
# `.default` is the registry the generator uses. It SHIPS EMPTY at 1.0.x —
# the mechanism (stamp the synced version, detect the gap, run ordered
# transforms) is the deliverable; the first concrete `1.x → 1.y` transform is
# a one-line `Migration.new(...)` addition here once a release needs one.
class MigrationRegistry
def initialize(migrations = [])
@migrations = migrations.sort_by(&:to)
end

# The registry the install generator runs during `--sync`. Empty today.
def self.default
@default ||= new(MIGRATIONS)
end

# No migrations to register yet — the mechanism is the 1.0 deliverable.
# Add ordered `Migration.new(to: "1.x.0", description: "...") { ... }`
# entries here as future releases change config knobs, routes, or templates.
MIGRATIONS = [].freeze

# The migrations a site last synced at `from_version` still needs, ascending
# by version — those in `(from_version, upto]`. `upto` defaults to the
# installed gem version so a migration targeting an unreleased version never
# applies (and never re-runs against the post-sync stamp).
def applicable(from_version, upto: DocsKit::VERSION)
from = Gem::Version.new(from_version.to_s)
ceiling = Gem::Version.new(upto.to_s)
@migrations.select { |migration| migration.to > from && migration.to <= ceiling }
end

# Run every applicable migration in order against the site, returning the
# flattened list of manual-cleanup warnings they couldn't safely automate.
def migrate!(from_version, root, generator, upto: DocsKit::VERSION)
applicable(from_version, upto: upto).flat_map { |migration| migration.call(root, generator) }
end
end
end
end
1 change: 1 addition & 0 deletions lib/generators/docs_kit/install/templates/docs_kit.rb.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<%= self.class.synced_stamp %>
# frozen_string_literal: true

# docs-kit configuration — everything that makes this site look like YOUR docs.
Expand Down
153 changes: 151 additions & 2 deletions spec/generators/install_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
require "tmpdir"
require "rails/generators"
require "generators/docs_kit/install/install_generator"
require "generators/docs_kit/install/migration"
require "generators/docs_kit/install/migration_registry"

# The install generator never touches a booted Rails app — it only reads/writes
# files under destination_root via Thor. So we exercise it against a throwaway
Expand Down Expand Up @@ -430,10 +432,14 @@ def capture_stream
write("config/initializers/docs_kit.rb", edited_config)
end

it "preserves the site's edited config byte-for-byte on re-run" do
it "preserves the site's edited config body on re-run (only prepends the inert version stamp)" do
run_generator

expect(read("config/initializers/docs_kit.rb")).to eq(edited_config)
result = read("config/initializers/docs_kit.rb")
# The edited config body is untouched — just prefixed with the synced-version
# stamp comment (an inert line the migration machinery reads on the next sync).
expect(result).to include(edited_config.rstrip)
expect(result).to eq("# docs-kit synced: v#{DocsKit::VERSION}\n#{edited_config}")
end

it "reports the skip and hints at the template for an upgrade diff" do
Expand Down Expand Up @@ -1033,4 +1039,147 @@ def render_page(view)
expect(output).not_to match(/Dockerfile is v|Dockerfile.*older/i)
end
end

# Version-aware sync: the generator records which docs-kit version a site was
# last synced at (a `# docs-kit synced: vX.Y.Z` stamp in the initializer) so a
# future `--sync` can run the ORDERED migrations between that version and the
# gem's current version — not just diff against head. The stamp is inert (a
# comment), lives in the one file every site has, and is injectable into an
# existing initializer the generator otherwise never rewrites.
describe "version stamping (records the last-synced docs-kit version)" do
let(:stamp) { "# docs-kit synced: v#{DocsKit::VERSION}" }

it "stamps the current gem version into the initializer on a full install" do
build_skeleton
run_generator

expect(read("config/initializers/docs_kit.rb")).to include(stamp)
end

it "injects the stamp into an existing (never-clobbered) initializer that lacks one" do
# A site created before this feature has an un-stamped initializer the
# generator must not rewrite. --sync injects the stamp comment without
# touching the site's config body.
build_skeleton
write("config/initializers/docs_kit.rb", <<~RUBY)
# frozen_string_literal: true
DocsKit.configure do |c|
c.brand = "My Hand-Edited Brand"
end
RUBY

run_generator(sync: true)

initializer = read("config/initializers/docs_kit.rb")
expect(initializer).to include(stamp)
# The site's edited config body is preserved.
expect(initializer).to include(%(c.brand = "My Hand-Edited Brand"))
end

it "updates a stale stamp to the current version on --sync (never duplicates it)" do
build_skeleton
write("config/initializers/docs_kit.rb", <<~RUBY)
# docs-kit synced: v0.9.0
# frozen_string_literal: true
DocsKit.configure { |c| c.brand = "X" }
RUBY

run_generator(sync: true)

initializer = read("config/initializers/docs_kit.rb")
expect(initializer).to include(stamp)
expect(initializer).not_to include("v0.9.0")
expect(initializer.scan("docs-kit synced:").size).to eq(1)
end

it "is idempotent — a second run leaves exactly one current stamp" do
build_skeleton
run_generator
run_generator(sync: true)

initializer = read("config/initializers/docs_kit.rb")
expect(initializer.scan("docs-kit synced:").size).to eq(1)
expect(initializer).to include(stamp)
end
end

# The payoff of the stamp: --sync computes the gap between the site's
# last-synced version and the gem's version and runs the ordered migrations
# in between, printing warn-only messages for anything a migration can't
# safely automate (the #24 drift pattern). The default registry ships EMPTY at
# 1.0.x, so these assert the WIRING (the gap is read, the registry is invoked,
# warnings surface) against an injected registry rather than a real transform.
describe "version-aware migrations (--sync runs ordered transforms across the gap)" do
# A registry whose one migration records that it ran (by writing a marker
# file into the site) and emits a warning — so we can assert the generator
# read the stamp, invoked the registry, and printed the warning. `to`
# defaults to the installed gem version (a migration introduced in THIS
# release): the realistic case, and within the registry's `upto` ceiling so
# it's applicable to a site stamped below it. A `to` above DocsKit::VERSION
# would be filtered as unreleased.
def stub_default_registry(to: DocsKit::VERSION, warnings: ["do the manual thing the migration can't automate"])
migration = DocsKit::Generators::Migration.new(to: to, description: "a migration") do |root, _gen|
File.write(File.join(root, ".migration-ran"), "yes")
warnings
end
registry = DocsKit::Generators::MigrationRegistry.new([migration])
allow(DocsKit::Generators::MigrationRegistry).to receive(:default).and_return(registry)
end

it "runs applicable migrations for a stamped site and prints their warnings" do
build_skeleton
run_generator # full install → stamps the current version
# Roll the stamp back so the stubbed migration (at the gem version) is applicable.
write("config/initializers/docs_kit.rb",
"# docs-kit synced: v0.9.0\n#{read('config/initializers/docs_kit.rb')}")
stub_default_registry

generator = described_class.new([], { sync: true }, destination_root: destination)
output = capture_stream { generator.invoke_all }

expect(exist?(".migration-ran")).to be(true)
expect(output).to include("do the manual thing the migration can't automate")
end

it "runs no migrations when the site is already at the current version" do
build_skeleton
run_generator # stamps current version
# A migration AT the current version has already been applied at that sync.
stub_default_registry(to: DocsKit::VERSION, warnings: [])

generator = described_class.new([], { sync: true }, destination_root: destination)
capture_stream { generator.invoke_all }

expect(exist?(".migration-ran")).to be(false)
end

it "treats a pre-feature (un-stamped) site as earliest (runs every migration)" do
build_skeleton
# A site created before the stamp landed: it HAS an initializer, but with
# no synced-version stamp. create_initializer never clobbers it, so it
# survives into run_migrations, which reads it as 0.0.0 (earliest) — every
# migration up to the gem version applies.
write("config/initializers/docs_kit.rb", <<~RUBY)
# frozen_string_literal: true
DocsKit.configure { |c| c.brand = "Legacy Site" }
RUBY
stub_default_registry

generator = described_class.new([], { sync: true }, destination_root: destination)
capture_stream { generator.invoke_all }

expect(exist?(".migration-ran")).to be(true)
end

it "does not run migrations on a full (non-sync) install" do
build_skeleton
stub_default_registry

generator = described_class.new([], {}, destination_root: destination)
capture_stream { generator.invoke_all }

# A full install is a fresh scaffold, not an upgrade — nothing to migrate.
expect(exist?(".migration-ran")).to be(false)
end
end
end
Loading
Loading