diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302be4d..f539063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" rails: name: Rails ${{ matrix.rails }} @@ -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: diff --git a/AGENTS.md b/AGENTS.md index d6d99f8..359ffe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `; 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 `; 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. @@ -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 diff --git a/bin/rails-defaults-check b/bin/rails-defaults-check new file mode 100755 index 0000000..5121266 --- /dev/null +++ b/bin/rails-defaults-check @@ -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 diff --git a/bin/rails-versions b/bin/rails-versions index 6c063f4..a3d95c4 100755 --- a/bin/rails-versions +++ b/bin/rails-versions @@ -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) diff --git a/config/application.rb b/config/application.rb index d8994d1..78592a9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/spec/ci/rails_defaults_spec.rb b/spec/ci/rails_defaults_spec.rb new file mode 100644 index 0000000..8503956 --- /dev/null +++ b/spec/ci/rails_defaults_spec.rb @@ -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 + 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 + 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