Skip to content
Open
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
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,15 @@ jobs:
steps:
- uses: actions/checkout@v7
- id: versions
# Every stable Rails MAJOR.MINOR >= 7.2 (the demo app's floor), from rubygems.org.
run: echo "versions=$(ruby bin/rails-versions 7.2)" >> "$GITHUB_OUTPUT"
name: Resolve the Rails matrix and guard the floor
# Every stable Rails MAJOR.MINOR >= the demo app's floor (config/application.rb), from rubygems.org.
# The floor guard runs in this same step (no extra step): the app's floor, the floor the gem
# advertises (README.md, llms.txt) and the matrix's oldest entry must agree, or this job -
# and with it CI OK - fails.
run: |
versions=$(ruby bin/rails-versions)
echo "versions=$versions" >> "$GITHUB_OUTPUT"
ruby bin/rails-defaults-check "$versions"
Comment thread
developerz-ai[bot] marked this conversation as resolved.
Resolved

rails:
name: Rails ${{ matrix.rails }}
Expand Down Expand Up @@ -276,7 +283,7 @@ jobs:
if: always()
# Every job here must pass; nothing is allowed to fail. Ruby head and Rails edge run monthly in
# canary.yml, where the developerz.ai `improve` checkup picks up what they find.
needs: [lint, audit, test, gem-specs, native, strict-ext, sanitizers, fallback, engines, rails, package]
needs: [lint, audit, test, gem-specs, native, strict-ext, sanitizers, fallback, engines, rails-versions, rails, package]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Writing ruby2html templates/components (here or in a user app)? Load [`llms.txt`
| Demo Rails app | `app/`, `config/`, `db/` | Not packaged; loads the gem via `require_relative '../lib/gem/ruby2html'` |
| Specs | `spec/gem/` (renderer, attributes, escaping, `render_parity_spec.rb`; `render_spec_helper.rb` runs each group against Native **and** PureRuby in one process), `spec/gem/rails/` (handler, compiler, components, middleware), `spec/features/`, `spec/benchmarks/` | Rails-booting via `spec/rails_helper.rb`; `rake spec:standalone` runs `spec/gem` minus `rails/` without Rails |
| Benchmarks | `bench/*.rb` (standalone), `spec/benchmarks/requests_spec.rb` (request-level vs ERB/Slim/Phlex) | |
| Scripts | `bin/gem-smoke`, `bin/changelog-notes`, `bin/build-ext`, `bin/audit-advisories` | Packaging smoke test; print a CHANGELOG section; CI-style extension builds (`--werror`, `--cc=clang`, `--sanitize`); audit one resolved lockfile against the Ruby advisory database (`bundle-audit check --update --gemfile-lock <lockfile>`; exits non-zero on an advisory, except the reviewed advisories the script accepts, scoped to the exact reviewed version the lockfile resolves) |
| Scripts | `bin/gem-smoke`, `bin/changelog-notes`, `bin/build-ext`, `bin/rails-versions`, `bin/rails-defaults-check`, `bin/audit-advisories` | Packaging smoke test; print a CHANGELOG section; CI-style extension builds (`--werror`, `--cc=clang`, `--sanitize`); the demo app's Rails floor (`bin/rails-versions --floor`) and the CI guard that keeps the app, the advertised floor and the matrix in sync (`bin/rails-defaults-check`); audit one resolved lockfile against the Ruby advisory database (`bundle-audit check --update --gemfile-lock <lockfile>`; exits non-zero on an advisory, except the reviewed advisories the script accepts, scoped to the exact reviewed version the lockfile resolves) |

- Supported: all maintained CRuby versions (>= 3.1, gemspec floor; CI tracks the official list — currently 3.3, 3.4, 4.0), JRuby (pure Ruby + the Java escaper), TruffleRuby (pure Ruby), Rails 7.2+.
- Dev Ruby: `.ruby-version` (latest stable). The CRuby CI matrix is generated automatically from the maintained-versions list, so a new release (e.g. a new 4.x) joins CI with no edit.
Expand Down Expand Up @@ -114,6 +114,7 @@ Writing ruby2html templates/components (here or in a user app)? Load [`llms.txt`
11. Public API used downstream — `Render.new(ctx, &blk)`, `#render`, `#plain`, `#component`, tag methods, `Ruby2html.escape_html`, `Ruby2html.native?`, `Ruby2html::Component`, `RailsHelper#html`, `ComponentHelper#html` / `#__ruby2html_renderer__`, the `.rb` handler, `HtmlBeautifierMiddleware` — breaking changes need a CHANGELOG entry ("Upgrading to 2.0") and a major bump.
12. Style: rubocop-rails_config, single quotes, `# frozen_string_literal: true`, nested modules, Rails-style indented `private` methods. C: gnu99, no warnings under `-Wall -Wextra`.
13. Never commit `lib/ruby2html/*.so`, `*.jar`, `*.class`, `tmp/`, `pkg/`, `*.gem`, `log/`.
14. **The demo app's Rails floor is derived, not hardcoded.** `config.load_defaults` in `config/application.rb` is the single source of truth: `bin/rails-versions` parses it (`--floor` prints it, no network) and derives the CI matrix from it. The matrix's oldest entry and the floor the gem advertises (`Rails X.Y+` in README.md, llms.txt and AGENTS.md) must equal the app's floor — `bin/rails-defaults-check` fails CI, naming every version compared, when they drift. A matrix entry older than the app's `load_defaults` cannot boot the demo app (`RuntimeError: Unknown version`); Rails edge is not a matrix entry (it runs monthly in `canary.yml`).

## Maintenance model

Expand Down
76 changes: 76 additions & 0 deletions bin/rails-defaults-check
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# Guard: the demo app's Rails floor, the floor the gem advertises and the
# oldest Rails line of the CI matrix must be the same version.
#
# ruby bin/rails-defaults-check # app floor vs README.md, llms.txt, AGENTS.md
# ruby bin/rails-defaults-check '["7.2","8.0"]' # ... and vs the matrix's oldest entry
#
# Exits 1 and names every version compared when they drift, 0 when they
# agree. Stdlib only: no Bundler, no Rails, no network.

require 'json'
require 'open3'
require 'rbconfig'

ROOT = File.expand_path('..', __dir__)
ADVERTISED_FILES = %w[README.md llms.txt AGENTS.md].freeze
ADVERTISED_FLOOR = /Rails (\d+(?:\.\d+)*)\+/

def app_floor
out, status = Open3.capture2e(RbConfig.ruby, File.join(ROOT, 'bin', 'rails-versions'), '--floor')
abort "bin/rails-versions --floor failed:\n#{out}" unless status.success?

out.strip
end

def advertised_floors(file)
source = File.read(File.join(ROOT, file))
source.scan(ADVERTISED_FLOOR).flatten.uniq
end

def matrix_floor(json)
lines = JSON.parse(json)
unless lines.is_a?(Array) && !lines.empty? && lines.all?(String)
abort "Rails matrix must be a non-empty array of versions, got: #{json}"
end

lines.min_by { |line| Gem::Version.new(line) }
rescue JSON::ParserError, ArgumentError => e
abort "Could not read the Rails matrix #{json.inspect}: #{e.message}"
end

def same_version?(one, other)
Gem::Version.new(one) == Gem::Version.new(other)
rescue ArgumentError
one == other
end

floor = app_floor
reported = [['app floor (config/application.rb)', floor]]
problems = []

ADVERTISED_FILES.each do |file|
versions = advertised_floors(file)
reported << [file, versions.empty? ? 'no "Rails X.Y+" advertised' : versions.join(', ')]
problems << file unless versions.size == 1 && same_version?(versions.first, floor)
end

matrix = ARGV[0]
if matrix
oldest = matrix_floor(matrix)
reported << ['CI matrix (oldest entry)', oldest]
problems << 'CI matrix' unless same_version?(oldest, floor)
end

if problems.empty?
summary = reported.map { |label, version| "#{label}: #{version}" }.join(', ')
puts "Rails floor #{floor}: #{summary} agree."
exit 0
end

warn 'Rails floor mismatch:'
reported.each { |label, version| warn " #{label}: #{version}" }
warn "Align #{problems.join(', ')} with config/application.rb: bin/rails-versions derives the CI matrix from it."
exit 1
70 changes: 64 additions & 6 deletions bin/rails-versions
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,75 @@
# rubygems.org: every MAJOR.MINOR >= MIN that has a stable release, e.g.
# bin/rails-versions 7.2 # => ["7.2","8.0","8.1"]
# Used by CI so new Rails releases join the matrix without editing anything.
#
# The floor is the demo app's Rails floor (config/application.rb), the single
# source of truth: the matrix always starts at the Rails the app boots on, so
# the app and the matrix cannot drift apart. An explicit MIN still overrides
# the listing.
#
# bin/rails-versions # => ["7.2","8.0","8.1"]
# bin/rails-versions 8.0 # => ["8.0","8.1"]
# bin/rails-versions --floor # => 7.2 (no network)

require 'json'
require 'net/http'

min = Gem::Version.new(ARGV.fetch(0, '7.2'))
ROOT = File.expand_path('..', __dir__)
APP_CONFIG = File.join(ROOT, 'config', 'application.rb')
LOAD_DEFAULTS = /^\s*config\.load_defaults\s+(?:"([^"]+)"|'([^']+)'|([0-9]+(?:\.[0-9]+)*))/
USAGE = <<~TEXT
Usage: bin/rails-versions [--floor] [MIN]

--floor print the demo app's Rails floor (config/application.rb) and exit
MIN list only Rails lines >= MIN (default: the demo app's floor)
TEXT

def app_floor
source = File.read(APP_CONFIG)
match = source.match(LOAD_DEFAULTS)
version = match && (match[1] || match[2] || match[3])
abort "Could not parse `config.load_defaults` from #{APP_CONFIG}" if version.nil? || version.empty?

version
end

def parse_version(version)
Gem::Version.new(version)
rescue ArgumentError
abort "Invalid Rails version: #{version.inspect}"
end

floor = app_floor
min_arg = nil
floor_only = false

ARGV.each do |arg|
case arg
when '--floor'
floor_only = true
when /\A-/
abort "Unknown option #{arg.inspect}\n#{USAGE}"
else
abort "Only one MIN argument is allowed\n#{USAGE}" unless min_arg.nil?

min_arg = arg
end
end

if floor_only
abort "--floor does not take a MIN argument\n#{USAGE}" unless min_arg.nil?

puts floor
exit 0
end

min = parse_version(min_arg || floor)
body = Net::HTTP.get(URI('https://rubygems.org/api/v1/versions/rails.json'))
lines = JSON.parse(body)
.reject { |v| v['prerelease'] }
.map { |v| Gem::Version.new(v['number']).segments.first(2).join('.') }
.uniq
.select { |line| Gem::Version.new(line) >= min }
.sort_by { |line| Gem::Version.new(line) }
.reject { |v| v['prerelease'] }
.map { |v| Gem::Version.new(v['number']).segments.first(2).join('.') }
.uniq
.select { |line| Gem::Version.new(line) >= min }
.sort_by { |line| Gem::Version.new(line) }
abort 'No Rails versions found' if lines.empty?
puts JSON.generate(lines)
6 changes: 5 additions & 1 deletion config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@

module Ruby2html
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
# The demo app's Rails floor, and the single source of truth for it:
# bin/rails-versions derives the CI matrix's oldest entry from this value,
# and bin/rails-defaults-check fails CI when the floor the gem advertises
# (README.md:26, llms.txt:5, AGENTS.md:22) drifts from it. Raising this
# raises the app's floor: the owner's call, not CI's.
config.load_defaults 7.2

# Please, add to the `ignore` list any other `lib` subdirectories that do
Expand Down
78 changes: 78 additions & 0 deletions spec/ci/rails_defaults_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

require 'fileutils'
require 'json'
require 'open3'
require 'rbconfig'
require 'tmpdir'

# Offline guard for the demo app's Rails floor: the app, the floor the gem
# advertises and the CI matrix must agree. No Rails, no network.
RSpec.describe 'bin/rails-defaults-check' do
let(:root) { File.expand_path('../..', __dir__) }
let(:app_config) { File.join(root, 'config', 'application.rb') }
let(:app_floor) { File.read(app_config)[/config\.load_defaults\s+(\d+(?:\.\d+)*)/, 1] }
# The files that advertise the floor the gem supports: the guard compares
# each of them with the app's floor and names the ones that drift.
let(:advertised_files) { %w[README.md llms.txt AGENTS.md] }
# A Rails version the app does not use: the next major, derived so a
# legitimate floor change needs no edit here.
let(:divergent_floor) { "#{app_floor.to_i + 1}.0" }

def run_script(*args)
Open3.capture2e(RbConfig.ruby, *args, chdir: root)
end

# A copy of the files the guard reads, so a drifted copy can be checked
# without touching the checkout.
def copy_guard_tree(dir)
FileUtils.mkdir_p(File.join(dir, 'bin'))
FileUtils.mkdir_p(File.join(dir, 'config'))
FileUtils.cp(app_config, File.join(dir, 'config', 'application.rb'))
%w[rails-defaults-check rails-versions].each do |script|
FileUtils.cp(File.join(root, 'bin', script), File.join(dir, 'bin', script))
end
advertised_files.each { |file| FileUtils.cp(File.join(root, file), File.join(dir, file)) }
end

it 'derives the Rails floor from the demo app' do
stdout, status = run_script(File.join(root, 'bin', 'rails-versions'), '--floor')

expect(status).to be_success
expect(app_floor).to match(/\A\d+(\.\d+)*\z/)
expect(stdout.strip).to eq(app_floor)
end

it 'passes when the app floor and the advertised floor agree' do
Comment thread
developerz-ai[bot] marked this conversation as resolved.
Resolved
stdout, status = run_script(File.join(root, 'bin', 'rails-defaults-check'))

expect(status).to be_success
expect(stdout).to include("app floor (config/application.rb): #{app_floor}")
# Every advertised file is compared and reported with the floor found in
# it: a guard that stopped comparing them would not name them at all.
advertised_files.each { |file| expect(stdout).to include("#{file}: #{app_floor}") }
end

it 'fails when an advertised file drifts from the app floor' do
Dir.mktmpdir do |dir|
copy_guard_tree(dir)
File.write(File.join(dir, 'AGENTS.md'), "Rails #{divergent_floor}+\n")

stdout, status = run_script(File.join(dir, 'bin', 'rails-defaults-check'))

expect(status).not_to be_success
expect(stdout).to include("app floor (config/application.rb): #{app_floor}")
expect(stdout).to include("AGENTS.md: #{divergent_floor}")
expect(stdout).to include('Align AGENTS.md with config/application.rb')
end
end

it 'fails when the CI matrix starts at another Rails than the app' do
Comment thread
developerz-ai[bot] marked this conversation as resolved.
Resolved
stdout, status = run_script(File.join(root, 'bin', 'rails-defaults-check'), JSON.generate([divergent_floor]))

expect(status).not_to be_success
expect(stdout).to include("app floor (config/application.rb): #{app_floor}")
expect(stdout).to include("CI matrix (oldest entry): #{divergent_floor}")
expect(stdout).to include('Align CI matrix with config/application.rb')
end
end
Loading