From 3a044c7d9cf96e7cb29fecb6e5f8990a9fc39063 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Fri, 3 Jul 2026 15:15:15 -0400 Subject: [PATCH 01/21] Sentry generator --- lib/generators/rolemodel/all_generator.rb | 1 + .../rolemodel/good_job/good_job_generator.rb | 2 - .../rolemodel/heroku/templates/app.json.tt | 7 +- .../rolemodel/readme/templates/README.md.erb | 2 +- .../semaphore/templates/semaphore.yml.erb | 2 - lib/generators/rolemodel/sentry/README.md | 25 +++++ lib/generators/rolemodel/sentry/USAGE | 13 +++ .../rolemodel/sentry/sentry_generator.rb | 105 ++++++++++++++++++ .../app/javascript/initializers/sentry.js | 34 ++++++ .../templates/config/initializers/sentry.rb | 70 ++++++++++++ lib/generators/rolemodel/webpack/README.md | 3 +- .../javascript/initializers/honeybadger.js | 26 ----- .../webpack/templates/webpack.config.js | 10 -- .../rolemodel/webpack/webpack_generator.rb | 11 -- .../rolemodel/sentry_generator_spec.rb | 47 ++++++++ .../rolemodel/webpack_generator_spec.rb | 1 - 16 files changed, 303 insertions(+), 56 deletions(-) create mode 100644 lib/generators/rolemodel/sentry/README.md create mode 100644 lib/generators/rolemodel/sentry/USAGE create mode 100644 lib/generators/rolemodel/sentry/sentry_generator.rb create mode 100644 lib/generators/rolemodel/sentry/templates/app/javascript/initializers/sentry.js create mode 100644 lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb delete mode 100644 lib/generators/rolemodel/webpack/templates/app/javascript/initializers/honeybadger.js create mode 100644 spec/generators/rolemodel/sentry_generator_spec.rb diff --git a/lib/generators/rolemodel/all_generator.rb b/lib/generators/rolemodel/all_generator.rb index ce5cae4f..c9a84d60 100644 --- a/lib/generators/rolemodel/all_generator.rb +++ b/lib/generators/rolemodel/all_generator.rb @@ -8,6 +8,7 @@ def run_all_the_generators generate 'rolemodel:heroku' generate 'rolemodel:readme' generate 'rolemodel:webpack' + generate 'rolemodel:sentry' generate 'rolemodel:react' generate 'rolemodel:slim' generate 'rolemodel:optics:all' diff --git a/lib/generators/rolemodel/good_job/good_job_generator.rb b/lib/generators/rolemodel/good_job/good_job_generator.rb index 8da7d0d3..ef47b564 100644 --- a/lib/generators/rolemodel/good_job/good_job_generator.rb +++ b/lib/generators/rolemodel/good_job/good_job_generator.rb @@ -64,8 +64,6 @@ def copy_initializers def finishing_notes say <<~NOTES - *** Reminder to update Honeybadger gem to version 5.7.0 or later to get correct GoodJob error notifications in Honeybadger - *** Reminder to also update your job classes to include appropriate concurrency controls (enqueue_limit/perform_limit with keys) NOTES end diff --git a/lib/generators/rolemodel/heroku/templates/app.json.tt b/lib/generators/rolemodel/heroku/templates/app.json.tt index 767d4da4..4554c39b 100644 --- a/lib/generators/rolemodel/heroku/templates/app.json.tt +++ b/lib/generators/rolemodel/heroku/templates/app.json.tt @@ -4,12 +4,15 @@ "postdeploy": "bin/rails db:seed" }, "env": { - "HONEYBADGER_API_KEY": { + "SENTRY_DSN": { "required": true }, - "HONEYBADGER_ENV": { + "SENTRY_ENVIRONMENT": { "required": true, "value": "review-app" + }, + "SENTRY_AUTH_TOKEN": { + "required": false } }, "addons": [ diff --git a/lib/generators/rolemodel/readme/templates/README.md.erb b/lib/generators/rolemodel/readme/templates/README.md.erb index b3fad0d3..8b50f596 100644 --- a/lib/generators/rolemodel/readme/templates/README.md.erb +++ b/lib/generators/rolemodel/readme/templates/README.md.erb @@ -81,7 +81,7 @@ When finished with the feature and the code has been reviewed, the commits shoul ## [Staging](http://staging..com) ## [Production](http://app..com) ## External services -* [HoneyBadger](http://honeybadger.io) +* [Sentry](https://sentry.io) * [Skylight](http://skylight.io) * [SendGrid](http://sendgrid.com/RoleModel) * [Heroku](http://herokuapp.com) diff --git a/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb b/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb index 1a2daf96..67aafcd0 100644 --- a/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb +++ b/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb @@ -11,8 +11,6 @@ global_job_config: env_vars: - name: RAILS_ENV value: test - - name: HONEYBADGER_SOURCE_MAP_DISABLED - value: 'true' - name: DATABASE_URL value: 'postgres://postgres:@0.0.0.0/<%= Rails.application.config.database_configuration['test']['database'] %>' - name: GOOGLE_CHROME_BIN diff --git a/lib/generators/rolemodel/sentry/README.md b/lib/generators/rolemodel/sentry/README.md new file mode 100644 index 00000000..caff93d3 --- /dev/null +++ b/lib/generators/rolemodel/sentry/README.md @@ -0,0 +1,25 @@ +# Sentry Generator + +## What you get + +Error monitoring and performance tracing via [Sentry](https://sentry.io) for both the Ruby and JavaScript sides of the app. + +### Ruby + +* The `sentry-rails` gem +* `config/initializers/sentry.rb` with sensible defaults: traces sampling, profiling, PII filtering via Rails' parameter filter, noisy-exception exclusion, and health-check transaction filtering +* Sentry user context wired into `app/controllers/application_controller.rb` via a `set_sentry_user` before_action (Devise-friendly, gated on `user_signed_in?`) + +### JavaScript + +* `@sentry/browser` and `@sentry/webpack-plugin` dependencies +* `app/javascript/initializers/sentry.js`, which initializes Sentry in production and staging only (avoids ad-blocker noise in development) and attaches the current user from a `current-user-id` meta tag +* The `sentryWebpackPlugin` wired into `webpack.config.js` to upload source maps in production + +Depends on the `rolemodel:webpack` generator having already created `webpack.config.js` and `app/javascript/application.js`. + +## After running + +* Update the `project` and `applicationKey` in `webpack.config.js`, and the matching `filterKeys` in `app/javascript/initializers/sentry.js`, to match your Sentry project. +* Set the `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, and `SENTRY_AUTH_TOKEN` environment variables. +* Render a `current-user-id` meta tag in your layout so the browser SDK can attach user context. diff --git a/lib/generators/rolemodel/sentry/USAGE b/lib/generators/rolemodel/sentry/USAGE new file mode 100644 index 00000000..35cc57a9 --- /dev/null +++ b/lib/generators/rolemodel/sentry/USAGE @@ -0,0 +1,13 @@ +Description: + Sets up Sentry error monitoring for both Ruby and JavaScript + +Example: + rails generate rolemodel:sentry + + This will: + Add the sentry-rails gem + Create config/initializers/sentry.rb + Add Sentry user context to app/controllers/application_controller.rb + Add @sentry/browser and @sentry/webpack-plugin JS dependencies + Create app/javascript/initializers/sentry.js + Wire the Sentry webpack plugin into webpack.config.js diff --git a/lib/generators/rolemodel/sentry/sentry_generator.rb b/lib/generators/rolemodel/sentry/sentry_generator.rb new file mode 100644 index 00000000..13c47af7 --- /dev/null +++ b/lib/generators/rolemodel/sentry/sentry_generator.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Rolemodel + class SentryGenerator < GeneratorBase + source_root File.expand_path('templates', __dir__) + + JS_DEPS = %w[ + @sentry/browser + @sentry/webpack-plugin + ] + + def install_gem + say 'Adding sentry-rails gem', :green + + bundle_command 'add sentry-rails' + end + + def add_ruby_initializer + say 'Setting up Sentry for Ruby error reporting', :green + + copy_file 'config/initializers/sentry.rb' + end + + def add_user_context + say 'Adding Sentry user context to ApplicationController', :green + + inject_into_class 'app/controllers/application_controller.rb', 'ApplicationController', + " before_action :set_sentry_user, if: :user_signed_in?\n" + + inject_into_file 'app/controllers/application_controller.rb', before: /^end\b/ do + <<-RUBY + + private + + def set_sentry_user + Sentry.set_user(id: current_user.id) + end + RUBY + end + end + + def add_js_dependencies + say 'Adding Sentry JS dependencies to package.json', :green + + run "yarn add --dev #{JS_DEPS.join(' ')}" + end + + def add_js_initializer + say 'Setting up Sentry for JS error reporting', :green + + copy_file 'app/javascript/initializers/sentry.js' + append_to_file 'app/javascript/application.js', <<~JS + import './initializers/sentry' + JS + end + + def configure_webpack + say 'Wiring Sentry into webpack.config.js', :green + + inject_into_file 'webpack.config.js', + "import { sentryWebpackPlugin } from '@sentry/webpack-plugin'\n", + after: "import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'\n" + + inject_into_file 'webpack.config.js', after: "'process.env.RAILS_ENV': JSON.stringify(process.env.RAILS_ENV),\n" do + <<-JS + 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), + 'process.env.SENTRY_ENVIRONMENT': JSON.stringify(process.env.SENTRY_ENVIRONMENT), + 'process.env.SENTRY_AUTH_TOKEN': JSON.stringify(process.env.SENTRY_AUTH_TOKEN), + JS + end + + gsub_file 'webpack.config.js', " })\n ].filter(Boolean)", <<-JS.chomp + }), + + // Upload source maps to Sentry in production for easier debugging + mode === 'production' && + !process.env.CI && + sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: 'rolemodel-software', + project: '#{app_name}', + telemetry: false, + applicationKey: 'app-frontend' + }) + ].filter(Boolean) + JS + end + + def finishing_notes + say <<~NOTES + + *** Update the sentryWebpackPlugin `project` and `applicationKey` in webpack.config.js, + and the matching `filterKeys` in app/javascript/initializers/sentry.js, to your Sentry project. + + *** Set the SENTRY_DSN, SENTRY_ENVIRONMENT, and SENTRY_AUTH_TOKEN environment variables. + NOTES + end + + private + + def app_name + Rails.application.class.module_parent_name.underscore + end + end +end diff --git a/lib/generators/rolemodel/sentry/templates/app/javascript/initializers/sentry.js b/lib/generators/rolemodel/sentry/templates/app/javascript/initializers/sentry.js new file mode 100644 index 00000000..4d88614d --- /dev/null +++ b/lib/generators/rolemodel/sentry/templates/app/javascript/initializers/sentry.js @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/browser' + +// Only initialize Sentry in production and staging environments +// This avoids issues with ad blockers during local development +const environment = process.env.SENTRY_ENVIRONMENT || process.env.RAILS_ENV +const shouldInitialize = environment === 'production' || environment === 'staging' + +if (shouldInitialize) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: environment, + sendDefaultPii: false, + tracesSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [ + Sentry.thirdPartyErrorFilterIntegration({ + // Must match the applicationKey in the sentryWebpackPlugin configuration + filterKeys: ['app-frontend'], + behaviour: 'drop-error-if-exclusively-contains-third-party-frames' + }) + ], + + // Capture user context from meta tags rendered by Rails + beforeSend(event) { + const userId = document.querySelector('meta[name="current-user-id"]')?.content + + if (userId) { + event.user = { id: userId } + } + + return event + } + }) +} diff --git a/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb b/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb new file mode 100644 index 00000000..954034a9 --- /dev/null +++ b/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +Sentry.init do |config| + config.dsn = ENV['SENTRY_DSN'] + config.enable_logs = true + config.debug = Rails.env.local? + + # Traces sampling — 5% for HTTP requests, 10% for other transactions + config.traces_sampler = lambda do |sampling_context| + transaction_context = sampling_context[:transaction_context] + + case transaction_context[:op] + when /^http/ + 0.05 + else + 0.10 + end + end + + # Enable profiling at 100% of sampled transactions + config.profiles_sample_rate = 1.0 + + # Breadcrumbs configuration + config.breadcrumbs_logger = [:sentry_logger, :http_logger] + + # Exclude common noisy exceptions + config.excluded_exceptions += [ + 'ActiveRecord::RecordNotFound', + 'ActionController::RoutingError', + ] + + # Include local variables for debugging + config.include_local_variables = true + + # Filter out health check transactions + config.before_send_transaction = lambda do |event, _hint| + return nil if event.transaction&.match?(/health|ping|metrics/) + event + end + + # Filter sensitive data using Rails' parameter filter + filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters) + config.send_default_pii = false + + config.before_send = lambda do |event, _hint| + if event.extra + event.extra = filter.filter(event.extra) + end + + if event.user + event.user = filter.filter(event.user) + end + + if event.contexts + event.contexts = filter.filter(event.contexts) + end + + event + end + + config.enabled_patches << :logger + config.std_lib_logger_filter = proc do |_logger, _message, severity| + [:warn, :error, :fatal].include?(severity) + end + + config.rails.register_error_subscriber = Rails.env.production? + config.rails.structured_logging.subscribers = { + action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber, + } +end diff --git a/lib/generators/rolemodel/webpack/README.md b/lib/generators/rolemodel/webpack/README.md index e0fd16a5..ce7551c4 100644 --- a/lib/generators/rolemodel/webpack/README.md +++ b/lib/generators/rolemodel/webpack/README.md @@ -5,4 +5,5 @@ * Webpack v5 * Uses `esbuild-loader` instead of Babel to transpile JS * Uses PostCSS to compile CSS and SCSS -* Honeybadger error monitoring for JS. Run `bundle exec honeybadger install [YOUR API KEY HERE]` to setup Honeybadger for Ruby. + +For error monitoring, run the `rolemodel:sentry` generator. diff --git a/lib/generators/rolemodel/webpack/templates/app/javascript/initializers/honeybadger.js b/lib/generators/rolemodel/webpack/templates/app/javascript/initializers/honeybadger.js deleted file mode 100644 index ca7a9705..00000000 --- a/lib/generators/rolemodel/webpack/templates/app/javascript/initializers/honeybadger.js +++ /dev/null @@ -1,26 +0,0 @@ -import Honeybadger from '@honeybadger-io/js' - -if (process.env.RAILS_ENV === 'production') { - Honeybadger.configure({ - apiKey: process.env.HONEYBADGER_API_KEY, - environment: process.env.HONEYBADGER_ENV,// ‘production’ or ‘review-app’ from app.json - revision: process.env.SOURCE_VERSION // provided by heroku - }) - - const IGNORE_ERRORS = [ - /AbortError/, - /UnhandledPromiseRejectionWarning: {}/, - /UnhandledPromiseRejectionWarning.*Load failed/, - /UnhandledPromiseRejectionWarning: Object Not Found Matching/, - /UnhandledPromiseRejectionWarning.*Failed to fetch/, - /ResizeObserver loop completed with undelivered notifications./, - ] - - Honeybadger.beforeNotify((notice) => { - for (const ignoreError of IGNORE_ERRORS) { - if (ignoreError.test(notice.message)) { - return false - } - } - }) -} diff --git a/lib/generators/rolemodel/webpack/templates/webpack.config.js b/lib/generators/rolemodel/webpack/templates/webpack.config.js index 2b91953d..41bea13e 100644 --- a/lib/generators/rolemodel/webpack/templates/webpack.config.js +++ b/lib/generators/rolemodel/webpack/templates/webpack.config.js @@ -1,7 +1,6 @@ import path from 'path' import webpack from 'webpack' import TerserPlugin from 'terser-webpack-plugin' -import HoneybadgerSourceMapPlugin from '@honeybadger-io/webpack' import MiniCssExtractPlugin from 'mini-css-extract-plugin' import CssMinimizerPlugin from 'css-minimizer-webpack-plugin' @@ -88,17 +87,8 @@ export default { // Replace ENV variables at build time new webpack.DefinePlugin({ - 'process.env.HONEYBADGER_API_KEY': JSON.stringify(process.env.HONEYBADGER_API_KEY), - 'process.env.HONEYBADGER_ENV': JSON.stringify(process.env.HONEYBADGER_ENV), 'process.env.RAILS_ENV': JSON.stringify(process.env.RAILS_ENV), 'process.env.SOURCE_VERSION': JSON.stringify(process.env.SOURCE_VERSION) - }), - - // Send source maps to HoneyBadger in production for easier debugging - (mode === 'production' && !process.env.CI) && new HoneybadgerSourceMapPlugin({ - apiKey: process.env.HONEYBADGER_API_KEY, - assetsUrl: process.env.ASSETS_URL, - revision: process.env.SOURCE_VERSION }) ].filter(Boolean) } diff --git a/lib/generators/rolemodel/webpack/webpack_generator.rb b/lib/generators/rolemodel/webpack/webpack_generator.rb index d62c37b6..1e855b81 100644 --- a/lib/generators/rolemodel/webpack/webpack_generator.rb +++ b/lib/generators/rolemodel/webpack/webpack_generator.rb @@ -3,8 +3,6 @@ class WebpackGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) DEV_DEPS = %w[ - @honeybadger-io/webpack - @honeybadger-io/js esbuild esbuild-loader webpack @@ -50,15 +48,6 @@ def add_npm_packages run "yarn add --dev #{dependencies.join(' ')}" end - def honeybadger_setup - say 'Setting up Honeybadger for JS error reporting', :green - - copy_file 'app/javascript/initializers/honeybadger.js' - append_to_file 'app/javascript/application.js', <<~JS - import './initializers/honeybadger' - JS - end - def replace_css_entrypoint_with_scss say 'Replacing CSS entrypoint file with SCSS version', :green diff --git a/spec/generators/rolemodel/sentry_generator_spec.rb b/spec/generators/rolemodel/sentry_generator_spec.rb new file mode 100644 index 00000000..68b723ce --- /dev/null +++ b/spec/generators/rolemodel/sentry_generator_spec.rb @@ -0,0 +1,47 @@ +RSpec.describe Rolemodel::SentryGenerator, type: :generator do + before do + # The webpack generator lays down the webpack.config.js and application.js + # that this generator injects into. + run_generator_against_test_app generator: ::Rolemodel::WebpackGenerator + run_generator_against_test_app + end + + it 'sets up the Ruby side' do + assert_file 'config/initializers/sentry.rb' do |content| + expect(content).to include('Sentry.init') + end + + assert_file 'Gemfile' do |content| + expect(content).to include('sentry-rails') + end + + assert_file 'app/controllers/application_controller.rb' do |content| + expect(content).to include('before_action :set_sentry_user, if: :user_signed_in?') + expect(content).to include('Sentry.set_user(id: current_user.id)') + end + end + + it 'sets up the JS side' do + assert_file 'app/javascript/initializers/sentry.js' do |content| + expect(content).to include("import * as Sentry from '@sentry/browser'") + end + + assert_file 'app/javascript/application.js' do |content| + expect(content).to include("import './initializers/sentry'") + end + + assert_file 'package.json' do |content| + dependencies = JSON.parse(content)['devDependencies'].keys + expect(dependencies).to include(*Rolemodel::SentryGenerator::JS_DEPS) + end + end + + it 'wires the Sentry plugin into webpack.config.js' do + assert_file 'webpack.config.js' do |content| + expect(content).to include("import { sentryWebpackPlugin } from '@sentry/webpack-plugin'") + expect(content).to include("'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN),") + expect(content).to include('sentryWebpackPlugin({') + expect(content).to include('].filter(Boolean)') + end + end +end diff --git a/spec/generators/rolemodel/webpack_generator_spec.rb b/spec/generators/rolemodel/webpack_generator_spec.rb index 07f76401..fdb91959 100644 --- a/spec/generators/rolemodel/webpack_generator_spec.rb +++ b/spec/generators/rolemodel/webpack_generator_spec.rb @@ -10,7 +10,6 @@ assert_file 'postcss.config.cjs' assert_file 'webpack.config.js' assert_file 'app/assets/stylesheets/application.scss' - assert_file 'app/javascript/initializers/honeybadger.js' end it 'adds webpack dev dependencies to package.json' do From 86bb71b2987c0412e34868c57b17e707945cfcd2 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Fri, 3 Jul 2026 15:39:11 -0400 Subject: [PATCH 02/21] Enable coreback rather than use yarn 1.22 --- README.md | 2 +- .../linters/eslint/eslint_generator.rb | 1 + lib/generators/rolemodel/lograge/README.md | 2 +- .../rolemodel/optics/base/base_generator.rb | 1 + .../rolemodel/react/react_generator.rb | 1 + .../rolemodel/readme/templates/README.md.erb | 3 +- .../semaphore/templates/semaphore.yml.erb | 8 ++-- .../rolemodel/sentry/sentry_generator.rb | 1 + .../tailored_select_generator.rb | 1 + .../jasmine_playwright_generator.rb | 4 +- .../testing/vitest/vitest_generator.rb | 1 + .../ui_components/modals/modals_generator.rb | 1 + .../ui_components/navbar/navbar_generator.rb | 1 + .../rolemodel/webpack/webpack_generator.rb | 6 +++ lib/rolemodel-rails.rb | 1 + lib/rolemodel/generator_base.rb | 37 +++++++++++++++++++ .../rolemodel/webpack_generator_spec.rb | 14 +++++++ 17 files changed, 75 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8dd0cb77..2f3c4392 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ bin/rails g ## Development -Install the versions of Node and Ruby specified in `.node-version` and `.ruby-version` on your machine. https://asdf-vm.com/ is a great tool for managing language versions. Then run `npm install -g yarn`. +Install the versions of Node and Ruby specified in `.node-version` and `.ruby-version` on your machine. https://asdf-vm.com/ is a great tool for managing language versions. Then run `corepack enable` to activate the Yarn 4+ version pinned by each project's `packageManager` field. ## Adding new Generators diff --git a/lib/generators/rolemodel/linters/eslint/eslint_generator.rb b/lib/generators/rolemodel/linters/eslint/eslint_generator.rb index 583d32d7..248cf01a 100644 --- a/lib/generators/rolemodel/linters/eslint/eslint_generator.rb +++ b/lib/generators/rolemodel/linters/eslint/eslint_generator.rb @@ -18,6 +18,7 @@ class EslintGenerator < GeneratorBase ].freeze def install_eslint + ensure_yarn run "yarn add --dev #{DEV_DEPENDENCIES.join(' ')}" end diff --git a/lib/generators/rolemodel/lograge/README.md b/lib/generators/rolemodel/lograge/README.md index 0ec4e9c3..403d0002 100644 --- a/lib/generators/rolemodel/lograge/README.md +++ b/lib/generators/rolemodel/lograge/README.md @@ -7,4 +7,4 @@ Installs [Lograge](https://github.com/roidrage/lograge). Lograge is an opinionated gem that slims down the amount of logs that Rails generates, with the goal of reducing -noise that's handled by other services. For instance, it removes view rendering logs, which is a duplication of Skylight. +noise that's handled by other services. diff --git a/lib/generators/rolemodel/optics/base/base_generator.rb b/lib/generators/rolemodel/optics/base/base_generator.rb index 2bb5d10b..60c200e0 100644 --- a/lib/generators/rolemodel/optics/base/base_generator.rb +++ b/lib/generators/rolemodel/optics/base/base_generator.rb @@ -6,6 +6,7 @@ class BaseGenerator < Rolemodel::GeneratorBase def add_optics_package say 'installing Optics package', :green + ensure_yarn run 'yarn add @rolemodel/optics' end diff --git a/lib/generators/rolemodel/react/react_generator.rb b/lib/generators/rolemodel/react/react_generator.rb index 661801db..7b5b283e 100644 --- a/lib/generators/rolemodel/react/react_generator.rb +++ b/lib/generators/rolemodel/react/react_generator.rb @@ -6,6 +6,7 @@ def add_npm_packages @add_react = yes?('Would you like to add react?') if @add_react + ensure_yarn run 'yarn add react react-dom' end end diff --git a/lib/generators/rolemodel/readme/templates/README.md.erb b/lib/generators/rolemodel/readme/templates/README.md.erb index 8b50f596..c061aa97 100644 --- a/lib/generators/rolemodel/readme/templates/README.md.erb +++ b/lib/generators/rolemodel/readme/templates/README.md.erb @@ -82,7 +82,6 @@ When finished with the feature and the code has been reviewed, the commits shoul ## [Production](http://app..com) ## External services * [Sentry](https://sentry.io) -* [Skylight](http://skylight.io) * [SendGrid](http://sendgrid.com/RoleModel) * [Heroku](http://herokuapp.com) @@ -113,4 +112,4 @@ The application is deployed to Heroku. They are also hosting the DNS. We certifi * Larry Anderson - 919-555-1213 # Copyright & licensing -Copyright (c) 2019 Closed Source @CompanyName +Copyright (c) 2026 Closed Source @CompanyName diff --git a/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb b/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb index 67aafcd0..e2577ccc 100644 --- a/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb +++ b/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb @@ -38,7 +38,7 @@ global_job_config: # Node setup - sem-version node $(cat .node-version) - - npm i -g yarn + - corepack enable # Cache setup - cache restore gems-$(checksum .ruby-version)-$(checksum Gemfile.lock) @@ -52,11 +52,11 @@ blocks: - name: Install Dependencies commands: - bundle check || bundle install - - yarn check || yarn install + - yarn install --immutable epilogue: commands: - cache store gems-$(checksum .ruby-version)-$(checksum Gemfile.lock) vendor/bundle - - cache store yarn-cache-$(checksum .node-version)-$(checksum yarn.lock) /home/semaphore/.cache/yarn + - cache store yarn-cache-$(checksum .node-version)-$(checksum yarn.lock) .yarn/cache - cache store yarn-node-modules-$(checksum .node-version)-$(checksum yarn.lock) node_modules - name: Ruby/JS/Linting execution_time_limit: @@ -91,7 +91,7 @@ blocks: - name: Webpack Compile and Store Assets commands: - bundle check || bundle install --without legacy_data_migration - - yarn check || yarn install + - yarn install --immutable - yarn build - cache store assets-public-$SEMAPHORE_WORKFLOW_ID public - name: System Tests diff --git a/lib/generators/rolemodel/sentry/sentry_generator.rb b/lib/generators/rolemodel/sentry/sentry_generator.rb index 13c47af7..7dc75fec 100644 --- a/lib/generators/rolemodel/sentry/sentry_generator.rb +++ b/lib/generators/rolemodel/sentry/sentry_generator.rb @@ -42,6 +42,7 @@ def set_sentry_user def add_js_dependencies say 'Adding Sentry JS dependencies to package.json', :green + ensure_yarn run "yarn add --dev #{JS_DEPS.join(' ')}" end diff --git a/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb b/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb index 47f2e79b..a2859382 100644 --- a/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb +++ b/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb @@ -7,6 +7,7 @@ class TailoredSelectGenerator < GeneratorBase def add_tailored_select_package say 'Installing Tailored Select package', :green + ensure_yarn run 'yarn add @rolemodel/tailored-select' end end diff --git a/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb b/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb index 21ad744f..58e51ba1 100644 --- a/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb +++ b/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb @@ -18,8 +18,8 @@ def fail_without_github_token raise Thor::InvocationError, 'a --github_package_token option or GITHUB_PACKAGES_TOKEN environment variable is required' if options[:github_package_token].blank? end - def yarn_init_unless_package_json_exists - run 'yarn init' unless File.exist?(File.expand_path('package.json', destination_root)) + def enable_corepack_and_yarn + ensure_yarn end def add_browser_test_script diff --git a/lib/generators/rolemodel/testing/vitest/vitest_generator.rb b/lib/generators/rolemodel/testing/vitest/vitest_generator.rb index cd2134ca..5564974c 100644 --- a/lib/generators/rolemodel/testing/vitest/vitest_generator.rb +++ b/lib/generators/rolemodel/testing/vitest/vitest_generator.rb @@ -23,6 +23,7 @@ def update_test_script def add_dev_dependencies say 'Adding new dev dependency to package.json', :green + ensure_yarn run "yarn add --dev #{DEV_DEPENDENCIES.join(' ')}" end diff --git a/lib/generators/rolemodel/ui_components/modals/modals_generator.rb b/lib/generators/rolemodel/ui_components/modals/modals_generator.rb index d18b61a0..87991b16 100644 --- a/lib/generators/rolemodel/ui_components/modals/modals_generator.rb +++ b/lib/generators/rolemodel/ui_components/modals/modals_generator.rb @@ -7,6 +7,7 @@ class ModalsGenerator < GeneratorBase def turbo_confirm say 'Installing Turbo Confirm package', :green + ensure_yarn run 'yarn add @rolemodel/turbo-confirm' end diff --git a/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb b/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb index 3538ffb4..a48b15f1 100644 --- a/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb +++ b/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb @@ -25,6 +25,7 @@ def insert_navbar_before_content def install_shoelace say 'Installing Shoelace package', :green + ensure_yarn run 'yarn add @shoelace-style/shoelace' end diff --git a/lib/generators/rolemodel/webpack/webpack_generator.rb b/lib/generators/rolemodel/webpack/webpack_generator.rb index 1e855b81..3fb6de06 100644 --- a/lib/generators/rolemodel/webpack/webpack_generator.rb +++ b/lib/generators/rolemodel/webpack/webpack_generator.rb @@ -29,6 +29,12 @@ def ensure_node_version create_file '.node-version', NODE_VERSION, force: true end + def enable_corepack_and_yarn + say 'Enabling Corepack and pinning the project to Yarn 4+', :green + + ensure_yarn + end + def force_node_to_use_es_modules say 'Configuring project to use ES Modules instead of CommonJS', :green diff --git a/lib/rolemodel-rails.rb b/lib/rolemodel-rails.rb index 06e1ef26..2b61880f 100644 --- a/lib/rolemodel-rails.rb +++ b/lib/rolemodel-rails.rb @@ -2,6 +2,7 @@ module Rolemodel NODE_VERSION = '24.12.0' + YARN_VERSION = '4.13.0' RUBY_VERSION = '4.0.1' GEM_LIB = File.expand_path(__dir__) diff --git a/lib/rolemodel/generator_base.rb b/lib/rolemodel/generator_base.rb index 52cfc99b..e3fbc1fb 100644 --- a/lib/rolemodel/generator_base.rb +++ b/lib/rolemodel/generator_base.rb @@ -13,5 +13,42 @@ class GeneratorBase < ::Rails::Generators::Base def run_bundle bundle_command("install --quiet", "BUNDLE_IGNORE_MESSAGES" => "1") end + + # Enable Corepack and pin the project to Yarn 4+ (instead of the classic + # Yarn 1.22). Idempotent, so any generator can call it before running a + # `yarn` command to guarantee the modern toolchain is in place. + def ensure_yarn + return if @yarn_ensured + + run 'corepack enable' + + unless File.exist?(File.expand_path('package.json', destination_root)) + create_file 'package.json', JSON.pretty_generate({}) + "\n" + end + + # Configure the node-modules linker so webpack, Playwright, and the Rails + # asset pipeline keep working (Yarn 4 defaults to Plug'n'Play otherwise). + create_file '.yarnrc.yml', "nodeLinker: node-modules\n", force: true + + pin_yarn_version + ignore_yarn_install_state + + @yarn_ensured = true + end + + def pin_yarn_version + modify_json_file('package.json') do |hash| + hash['packageManager'] = "yarn@#{YARN_VERSION}" + hash + end + end + + def ignore_yarn_install_state + gitignore = File.expand_path('.gitignore', destination_root) + return create_file '.gitignore', "/.yarn/install-state.gz\n" unless File.exist?(gitignore) + return if File.read(gitignore).include?('/.yarn/install-state.gz') + + append_to_file '.gitignore', "\n/.yarn/install-state.gz\n" + end end end diff --git a/spec/generators/rolemodel/webpack_generator_spec.rb b/spec/generators/rolemodel/webpack_generator_spec.rb index fdb91959..48a083ef 100644 --- a/spec/generators/rolemodel/webpack_generator_spec.rb +++ b/spec/generators/rolemodel/webpack_generator_spec.rb @@ -17,4 +17,18 @@ expect(JSON.parse(content)['devDependencies'].keys).to include(*dev_dependencies) end end + + it 'pins the project to Yarn 4+ via Corepack' do + assert_file 'package.json' do |content| + expect(JSON.parse(content)['packageManager']).to eq "yarn@#{Rolemodel::YARN_VERSION}" + end + + assert_file '.yarnrc.yml' do |content| + expect(content).to include('nodeLinker: node-modules') + end + + assert_file '.gitignore' do |content| + expect(content).to include('/.yarn/install-state.gz') + end + end end From ec161bb5fdb33311e2cb19534b177ff4629d5dd8 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 08:16:43 -0400 Subject: [PATCH 03/21] Conditionally add tailor select --- .../rolemodel/simple_form/README.md | 6 ++++ lib/generators/rolemodel/simple_form/USAGE | 4 +++ .../simple_form/simple_form_generator.rb | 6 ++++ .../tailored_select_input.rb | 0 .../rolemodel/simple_form_generator_spec.rb | 36 ++++++++++++++++--- 5 files changed, 48 insertions(+), 4 deletions(-) rename lib/generators/rolemodel/simple_form/templates/{app/inputs => optional}/tailored_select_input.rb (100%) diff --git a/lib/generators/rolemodel/simple_form/README.md b/lib/generators/rolemodel/simple_form/README.md index cd2e56d4..e8c1867d 100644 --- a/lib/generators/rolemodel/simple_form/README.md +++ b/lib/generators/rolemodel/simple_form/README.md @@ -8,3 +8,9 @@ * SimpleForm scaffold generation template Adds SimpleForm for simplified and unified styling that automatically wraps inputs with classes + +## Custom inputs + +All standard custom inputs are included by default. The generator prompts you to +confirm whether to include the `tailored_select` custom input, since not every +project uses the tailored-select web component. diff --git a/lib/generators/rolemodel/simple_form/USAGE b/lib/generators/rolemodel/simple_form/USAGE index 3be4ad28..326f9f3f 100644 --- a/lib/generators/rolemodel/simple_form/USAGE +++ b/lib/generators/rolemodel/simple_form/USAGE @@ -9,3 +9,7 @@ Example: config/locales/simple_form.en.yml simple_form input wrapper classes simple_form template file + + All standard custom inputs are included by default. You will be + prompted to confirm whether to include the tailored_select custom + input, since not every project uses the tailored-select web component. diff --git a/lib/generators/rolemodel/simple_form/simple_form_generator.rb b/lib/generators/rolemodel/simple_form/simple_form_generator.rb index 377b1310..5b184790 100644 --- a/lib/generators/rolemodel/simple_form/simple_form_generator.rb +++ b/lib/generators/rolemodel/simple_form/simple_form_generator.rb @@ -16,5 +16,11 @@ def add_files copy_file 'config/initializers/simple_form.rb' copy_file 'config/locales/simple_form.en.yml' end + + def add_tailored_select_input + return unless yes?('Would you like to include the tailored_select custom input?') + + copy_file 'optional/tailored_select_input.rb', 'app/inputs/tailored_select_input.rb' + end end end diff --git a/lib/generators/rolemodel/simple_form/templates/app/inputs/tailored_select_input.rb b/lib/generators/rolemodel/simple_form/templates/optional/tailored_select_input.rb similarity index 100% rename from lib/generators/rolemodel/simple_form/templates/app/inputs/tailored_select_input.rb rename to lib/generators/rolemodel/simple_form/templates/optional/tailored_select_input.rb diff --git a/spec/generators/rolemodel/simple_form_generator_spec.rb b/spec/generators/rolemodel/simple_form_generator_spec.rb index 9952dd8c..0e93dba9 100644 --- a/spec/generators/rolemodel/simple_form_generator_spec.rb +++ b/spec/generators/rolemodel/simple_form_generator_spec.rb @@ -1,23 +1,51 @@ RSpec.describe Rolemodel::SimpleFormGenerator, type: :generator do - before { run_generator_against_test_app } - it 'generates a simple form initializer' do + allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(false) + run_generator_against_test_app + assert_file 'config/initializers/simple_form.rb' end - it 'generates custom input files' do + it 'generates the default custom input files' do + allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(false) + run_generator_against_test_app + assert_file 'app/inputs/collection_check_boxes_input.rb' assert_file 'app/inputs/collection_select_input.rb' assert_file 'app/inputs/grouped_collection_select_input.rb' assert_file 'app/inputs/segmented_control_input.rb' assert_file 'app/inputs/switch_checkbox_input.rb' - assert_file 'app/inputs/tailored_select_input.rb' end it 'generates slim scaffold templates for simple form' do + allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(false) + run_generator_against_test_app + assert_file 'lib/templates/slim/scaffold/_form.html.slim' do |content| expect(content).to include("<%=") expect(content).not_to include("<%%=") end end + + context 'when the tailored_select input is declined' do + before do + allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(false) + run_generator_against_test_app + end + + it 'does not generate the tailored_select input' do + assert_no_file 'app/inputs/tailored_select_input.rb' + end + end + + context 'when the tailored_select input is confirmed' do + before do + allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(true) + run_generator_against_test_app + end + + it 'generates the tailored_select input' do + assert_file 'app/inputs/tailored_select_input.rb' + end + end end From 4b6026bb55560bf6fdfe4e6cbf5a08a9249d5c19 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 11:33:00 -0400 Subject: [PATCH 04/21] Additional cleanup --- README.md | 10 +- lib/generators/rolemodel/core_setup/README.md | 27 +++ lib/generators/rolemodel/core_setup/USAGE | 13 ++ .../core_setup/core_setup_generator.rb | 21 +++ lib/generators/rolemodel/github/README.md | 4 + .../templates/workflows/deploy-production.yml | 28 +++ .../templates/workflows/deploy-staging.yml | 31 ++++ lib/generators/rolemodel/heroku/README.md | 8 + .../rolemodel/heroku/heroku_generator.rb | 43 +++++ .../templates/skills/deploy-app/SKILL.md | 171 ++++++++++++++++++ .../templates/deploy-production.yml | 28 +++ .../deploy-app/templates/deploy-staging.yml | 31 ++++ .../rolemodel/sentry/sentry_generator.rb | 10 + .../app/inputs/switch_checkbox_input.rb | 2 +- .../rspec/templates/spec/rails_helper.rb.tt | 5 +- .../rolemodel/core_setup_generator_spec.rb | 35 ++++ 16 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 lib/generators/rolemodel/core_setup/README.md create mode 100644 lib/generators/rolemodel/core_setup/USAGE create mode 100644 lib/generators/rolemodel/core_setup/core_setup_generator.rb create mode 100644 lib/generators/rolemodel/github/templates/workflows/deploy-production.yml create mode 100644 lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml create mode 100644 lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md create mode 100644 lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-production.yml create mode 100644 lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-staging.yml create mode 100644 spec/generators/rolemodel/core_setup_generator_spec.rb diff --git a/README.md b/README.md index 2f3c4392..0a721eff 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,14 @@ And then execute: ## Usage -Run all generators (useful on a new app) +Run the core generators (recommended on a new app — sets up our standard +baseline so the app is ready to push straight to Heroku) + +```shell +bin/rails g rolemodel:core_setup +``` + +Or run every generator, including app-specific extras like React, SaaS/Devise, and GoodJob ```shell bin/rails g rolemodel:all @@ -66,6 +73,7 @@ bin/rails g ## Generators +* [Core Setup](./lib/generators/rolemodel/core_setup) * [Github](./lib/generators/rolemodel/github) * [Semaphore](./lib/generators/rolemodel/semaphore) * [Heroku](./lib/generators/rolemodel/heroku) diff --git a/lib/generators/rolemodel/core_setup/README.md b/lib/generators/rolemodel/core_setup/README.md new file mode 100644 index 00000000..209bde21 --- /dev/null +++ b/lib/generators/rolemodel/core_setup/README.md @@ -0,0 +1,27 @@ +# Core Setup Generator + +Runs the core generators every new Rails app should have. Unlike +`rolemodel:all`, this skips the app-specific extras (React, SaaS/Devise, +GoodJob, Kaminari, etc.) so you get just the standard baseline — a new Rails +app you can push straight to Heroku right after generation. + +## Prerequisites + + - A freshly generated Rails app + +## What you get + + - [GitHub](../github) — standard GitHub configuration + - [Heroku](../heroku) — standard Heroku deployment configuration + - [Readme](../readme) — standard project README + - [Webpack](../webpack) — Webpack v5 for JS and CSS + - [Sentry](../sentry) — error monitoring for Ruby and JavaScript + - [Slim](../slim) — Slim templates + - [Optics](../optics) — Optics design system + - [Testing](../testing) — RSpec, FactoryBot, parallel_tests, TestProf + (pass `--js-runner` to include jasmine-playwright-runner) + - [SimpleForm](../simple_form) — SimpleForm with our configuration + - [Linters](../linters) — Rubocop and ESLint + - [UI Components](../ui_components) — flash, modals, navbar + - [Editors](../editors) — EditorConfig and recommended VSCode extensions + - [Lograge](../lograge) — condensed request logging diff --git a/lib/generators/rolemodel/core_setup/USAGE b/lib/generators/rolemodel/core_setup/USAGE new file mode 100644 index 00000000..0a3bc4dd --- /dev/null +++ b/lib/generators/rolemodel/core_setup/USAGE @@ -0,0 +1,13 @@ +Description: + Runs the core generators every new Rails app should have + + Sets up GitHub config, Heroku deployment, README, Webpack, Sentry, Slim, + Optics, testing (RSpec & friends), SimpleForm, linters, UI components, + editor config, and Lograge. The result is a new Rails app that is ready + to push straight to Heroku. + + Pass --js-runner to also include jasmine-playwright-runner for browser + JS testing. + +Example: + rails generate rolemodel:core_setup diff --git a/lib/generators/rolemodel/core_setup/core_setup_generator.rb b/lib/generators/rolemodel/core_setup/core_setup_generator.rb new file mode 100644 index 00000000..78636c46 --- /dev/null +++ b/lib/generators/rolemodel/core_setup/core_setup_generator.rb @@ -0,0 +1,21 @@ +module Rolemodel + class CoreSetupGenerator < ::Rolemodel::GeneratorBase + class_option :js_runner, type: :boolean, default: false, desc: 'Include jasmine-playwright-runner for browser testing' + + def run_the_core_generators + generate 'rolemodel:github' + generate 'rolemodel:heroku' + generate 'rolemodel:readme' + generate 'rolemodel:webpack' + generate 'rolemodel:sentry' + generate 'rolemodel:slim' + generate 'rolemodel:optics:all' + generate 'rolemodel:testing:all', options.js_runner? ? '--js-runner' : '--no-js-runner' + generate 'rolemodel:simple_form' + generate 'rolemodel:linters:all' + generate 'rolemodel:ui_components:all' + generate 'rolemodel:editors' + generate 'rolemodel:lograge' + end + end +end diff --git a/lib/generators/rolemodel/github/README.md b/lib/generators/rolemodel/github/README.md index 0fdfda59..6cf1a08b 100644 --- a/lib/generators/rolemodel/github/README.md +++ b/lib/generators/rolemodel/github/README.md @@ -9,6 +9,10 @@ It doesn't need to be run first, but the parallel_tests generator must be run in - CI workflow - A sensible default `ci.yml` to get you started with Github Actions. This will run linters, model tests, and system tests. - Along with the `ci.yml`, your `database.yml` will be modified to be able to be run in GHA. +- Deploy workflows + - `deploy-staging.yml` deploys to Heroku on every push to `main` (or manually); `deploy-production.yml` deploys manually via `workflow_dispatch`. + - Both target a GitHub deployment environment (`Staging`/`Production`) that provides `HEROKU_APP_NAME` and `HEROKU_APP_URL` variables, and authenticate with the org-level `HEROKU_IT_SUPPORT_API_KEY` secret and `HEROKU_IT_SUPPORT_EMAIL` variable. + - Note: the staging workflow will fail on pushes to `main` until the environment exists — run the `deploy-app` agent skill (installed by the heroku generator) to create the Heroku app and the GitHub environment. - Pull Request Template - When you open a Pull Request in Github it will use the Markdown file as a [template](./templates/pull_request_template.md) for the content of the PR. - Helpful for reminding collaborators to add specific details to the PR. diff --git a/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml b/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml new file mode 100644 index 00000000..0241377e --- /dev/null +++ b/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml @@ -0,0 +1,28 @@ +name: Deploy to Heroku Production + +on: + workflow_dispatch: + +concurrency: production-deployment + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: Production + url: ${{ vars.HEROKU_APP_URL }} + steps: + - uses: actions/checkout@v6 + + - name: Install Heroku CLI + run: | + curl https://cli-assets.heroku.com/install.sh | sh + + - uses: akhileshns/heroku-deploy@v3.15.15 + with: + heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} + heroku_app_name: ${{ vars.HEROKU_APP_NAME }} + heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} + healthcheck: '${{ vars.HEROKU_APP_URL }}/up' diff --git a/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml b/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml new file mode 100644 index 00000000..eb4a7860 --- /dev/null +++ b/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml @@ -0,0 +1,31 @@ +name: Deploy to Heroku Staging + +on: + workflow_dispatch: + push: + branches: + - main + +concurrency: staging-deployment + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: Staging + url: ${{ vars.HEROKU_APP_URL }} + steps: + - uses: actions/checkout@v6 + + - name: Install Heroku CLI + run: | + curl https://cli-assets.heroku.com/install.sh | sh + + - uses: akhileshns/heroku-deploy@v3.15.15 + with: + heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} + heroku_app_name: ${{ vars.HEROKU_APP_NAME }} + heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} + healthcheck: '${{ vars.HEROKU_APP_URL }}/up' diff --git a/lib/generators/rolemodel/heroku/README.md b/lib/generators/rolemodel/heroku/README.md index 1781c971..2d652fee 100644 --- a/lib/generators/rolemodel/heroku/README.md +++ b/lib/generators/rolemodel/heroku/README.md @@ -4,5 +4,13 @@ * Procfile configured for a server process and a release command to run migrations * Basic app.json preconfigured with a script to initialize the database and common environment variables, addons, and buildpacks +* A `deploy-app` agent skill (`.claude/skills/deploy-app/`, referenced from `AGENTS.md`) that + completes deployment setup: cleans up the generated Gemfile (merges duplicate groups, removes + comments, alphabetizes), verifies the test suite and RuboCop pass, creates the Sentry project + and wires up the DSN, creates and deploys the Heroku staging app (buildpacks, dynos, Postgres, + Papertrail), and creates the GitHub `Staging` environment with the + `HEROKU_APP_NAME`/`HEROKU_APP_URL` variables the deploy workflow consumes. The skill is LLM-agnostic (Agent Skills format): run `/deploy-app` in + Claude Code, or point any other coding agent at the SKILL.md. Requires the Heroku CLI, the + GitHub CLI, and the Sentry MCP server. This is the basic config needed to deploy to Heroku. diff --git a/lib/generators/rolemodel/heroku/heroku_generator.rb b/lib/generators/rolemodel/heroku/heroku_generator.rb index 186dc962..88b439f7 100644 --- a/lib/generators/rolemodel/heroku/heroku_generator.rb +++ b/lib/generators/rolemodel/heroku/heroku_generator.rb @@ -15,6 +15,30 @@ def install_procfile template 'Procfile' end + def install_deploy_app_skill + say 'Install deploy-app agent skill', :green + + directory 'skills/deploy-app', '.claude/skills/deploy-app' + + if File.exist?(File.join(destination_root, 'AGENTS.md')) + append_to_file 'AGENTS.md', agents_md_skill_entry + else + create_file 'AGENTS.md', "# Agent instructions\n#{agents_md_skill_entry}" + end + end + + def pin_ruby_version_for_buildpack + say 'Pin the Ruby version in the Gemfile so the Heroku buildpack respects it.', :green + + # A bare .ruby-version file is not enough: without a `ruby` directive the version + # never lands in Gemfile.lock, so the Heroku Ruby buildpack falls back to its own + # default and can install an incompatible Ruby. Tie the Gemfile to .ruby-version. + gemfile = File.join(destination_root, 'Gemfile') + return if File.exist?(gemfile) && File.read(gemfile).match?(/^\s*ruby\s/) + + inject_into_file 'Gemfile', "\nruby file: '.ruby-version'\n", after: /^source .*$/ + end + def force_ssl say 'Require SSL for production environment.', :green @@ -48,5 +72,24 @@ def create_assets_rake_tasks # rubocop:disable Metrics/MethodLength end RAKE end + + private + + def agents_md_skill_entry + <<~MD + + ## Agent skills + + Reusable, agent-agnostic task instructions live in `.claude/skills/` (Agent Skills + format). If your agent does not auto-discover that directory, read the relevant + `SKILL.md` and follow it directly. + + * `deploy-app` (`.claude/skills/deploy-app/SKILL.md`) — completes deployment setup: + cleans up the generated Gemfile, verifies the test suite and RuboCop pass, creates + the Sentry project and wires up the DSN, creates and deploys the Heroku staging + app, and creates the GitHub `Staging` environment + deploy workflow. Use when + asked to set up staging, Heroku, or deployment. + MD + end end end diff --git a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md new file mode 100644 index 00000000..781d7e4c --- /dev/null +++ b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md @@ -0,0 +1,171 @@ +--- +name: deploy-app +description: > + Set up deployment for a RoleModel Rails app after running the rolemodel_rails + core_setup generator. Cleans up the generated Gemfile, verifies the test suite and + RuboCop pass, creates the Sentry project and wires up the DSN, creates and deploys + the Heroku app (buildpacks, dynos, Postgres, Papertrail), and creates the GitHub + deployment environment + deploy workflow. Use when the user says "deploy app", + "set up staging", "set up heroku", "set up sentry for deploy", or "set up production + deploy". Defaults to staging; pass "production" for the production flow. +--- + +# Deploy App + +Completes deployment setup for a RoleModel Rails app in five phases: Gemfile cleanup, +green build (tests + RuboCop), Sentry, Heroku, GitHub. Default mode is **staging**. If +invoked with `production`, follow the [Production mode](#production-mode) differences. + +Every step must be **idempotent**: check whether the resource already exists before +creating it, and skip or repair rather than failing. Report each phase's outcome as you +go. Ask the user questions **one at a time**. + +## Phase 0: Preflight + +Run these checks before touching anything. Stop and tell the user how to fix any that +fail: + +1. `git remote get-url origin` — must be a GitHub repo. Derive `REPO_NAME` from + `gh repo view --json name -q .name` (fallback: basename of the origin URL). +2. `gh auth status` — GitHub CLI authenticated. +3. `heroku auth:whoami` — Heroku CLI installed and logged in. +4. Sentry MCP tools available (try listing organizations). If no Sentry MCP server is + connected, ask the user to add/authorize the Sentry MCP server + (https://mcp.sentry.dev) in their coding agent's MCP configuration and stop — do not + fall back to another mechanism. +5. Read `Procfile` — note whether a `worker` process exists. +6. Read `config/initializers/sentry.rb` — determine how the DSN is consumed: + - reads `ENV['SENTRY_DSN']` → **env-var mode** (RoleModel standard) + - reads `Rails.application.credentials...` → **credentials mode** + +Set `APP_NAME` = `-staging` (staging) or `` (production), but +confirm it with the user before creating anything on Heroku. + +## Phase 1: Gemfile cleanup + +The rolemodel_rails generators append gems as they run, leaving multiple blocks for the +same group and top-level gems scattered between them. Normalize the Gemfile first (skip +this phase if it's already clean): + +1. Merge duplicate `group` blocks so each unique group combination + (e.g. `:development, :test`, `:development`, `:test`) appears exactly once. +2. Remove all comments, including commented-out gems (e.g. `# gem "redis" ...`). +3. Alphabetize the gems — the top-level list and the list within each group. +4. Preserve every gem's version constraint and options (`require:`, `platforms:`, + `path:`, etc.) exactly as written. File order: `source`, then `ruby` (if present), + then top-level gems, then the group blocks. +5. Run `bundle install` and confirm it succeeds. `git diff Gemfile.lock` must show no + changes — this is an ordering-only cleanup; if the lockfile changed, you altered a + dependency and must fix it. + +## Phase 2: Green build + +Do not deploy a broken app. Both checks must pass before continuing to any later phase: + +1. RuboCop: `bin/rubocop` (fallback: `bundle exec rubocop`). If there are offenses, + apply safe autocorrections (`rubocop -a`), then fix the remainder by hand. +2. Test suite: `bin/rspec` (fallback: `bundle exec rspec`). Fix any failures at the + root cause — never skip, pend, or delete tests to get to green. +3. Re-run both until clean, then commit the Gemfile cleanup and any fixes (the deploy + later pushes this branch, so everything must be committed). + +## Phase 3: Sentry project + DSN + +1. List the Sentry organizations/teams via the Sentry MCP. Ask the user which team the + project belongs under (skip the question if there is only one option). +2. Check whether a project named `REPO_NAME` already exists in that org. If it does, + reuse it and fetch its DSN instead of creating a duplicate. +3. Otherwise create the project: slug/name = `REPO_NAME`, platform `ruby-rails`, + assigned to the confirmed team. +4. Fetch the project's client key (DSN). +5. Deliver the DSN according to the mode detected in preflight: + - **env-var mode**: no code change needed. The DSN is set as a Heroku config var in + Phase 4 (`SENTRY_DSN`, plus `SENTRY_ENVIRONMENT=staging`). Mention that local + error reporting (if ever wanted) uses the same `SENTRY_DSN` env var. + - **credentials mode**: do NOT edit credentials yourself. Print the DSN and exact + instructions — e.g. `bin/rails credentials:edit --environment production`, add + `sentry_dsn: ` — then wait for the user to confirm they've saved it. Verify + afterwards with + `bin/rails runner "abort 'missing' unless Rails.application.credentials.sentry_dsn"` + (adjust env/key path to match the initializer) before moving on. + +## Phase 4: Heroku app + +1. `heroku teams` — ask the user which team to create the app in. **Do not create the + app until they confirm the team and the app name** (default `-staging`). +2. Ensure the current branch is pushed: check `git status` and + `git rev-parse @ @{u}`. If there's no upstream or local is ahead, ask the user to + push (or push for them if they say so) before continuing. +3. Create the app if it doesn't exist (`heroku apps:info -a $APP_NAME` to check): + `heroku apps:create $APP_NAME --team ` +4. Buildpacks, in this exact order (check `heroku buildpacks -a $APP_NAME` first): + 1. `heroku buildpacks:add heroku/nodejs -a $APP_NAME` + 2. `heroku buildpacks:add heroku/ruby -a $APP_NAME` +5. Add-ons (skip any that already exist per `heroku addons -a $APP_NAME`): + - `heroku addons:create heroku-postgresql:essential-0 -a $APP_NAME` + - Papertrail: run `heroku addons:plans papertrail` and pick the plan whose + name/description matches "Development" (case-insensitive); if no plan matches, + show the plan list and ask the user which to use. Then + `heroku addons:create papertrail: -a $APP_NAME`. +6. Config vars: + - `RAILS_MASTER_KEY`: **never read `config/master.key` (or + `config/credentials/production.key`) — the key must not enter the model's context.** + Pause and tell the user to run this in their own terminal: + `heroku config:set RAILS_MASTER_KEY=$(cat config/master.key) -a $APP_NAME` + (substitute `config/credentials/production.key` if per-environment credentials are + in use). Once they confirm, verify presence without exposing the value: + `heroku config --json -a $APP_NAME | jq 'has("RAILS_MASTER_KEY")'` — must be `true`. + - env-var mode only, set these yourself: + `heroku config:set SENTRY_DSN= SENTRY_ENVIRONMENT=staging -a $APP_NAME` +7. Enable runtime dyno metadata (idempotent) so Sentry can detect releases: + `heroku labs:enable runtime-dyno-metadata -a $APP_NAME`. This adds `HEROKU_*` + env vars (e.g. `HEROKU_SLUG_COMMIT`) on the next release; without it the release + command logs a warning about dyno metadata. +8. Initial deploy from the local machine: + - `heroku git:remote -a $APP_NAME -r heroku-staging` + - `git push heroku-staging :main` + - If the build or release phase fails, read the build output / `heroku logs` and fix + the root cause before proceeding — do not create the GitHub environment until the + app deploys and boots. +9. Dyno formation (after the first successful deploy): + - `heroku ps:type web=basic -a $APP_NAME` + - If the Procfile has a `worker` process: `heroku ps:scale worker=1:basic -a $APP_NAME` +10. Verify: get the app URL from `heroku apps:info -a $APP_NAME --json` (`web_url`), then + curl `/up` and confirm a 200. Save the URL as `APP_URL` (no trailing slash). + +## Phase 5: GitHub environment + deploy workflow + +1. Create the environment (idempotent PUT): + `gh api -X PUT repos/{owner}/{repo}/environments/Staging` +2. Set the environment variables: + - `gh variable set HEROKU_APP_NAME --env Staging --body "$APP_NAME"` + - `gh variable set HEROKU_APP_URL --env Staging --body "$APP_URL"` +3. If `.github/workflows/deploy-staging.yml` doesn't exist, copy it from this skill's + `templates/deploy-staging.yml`, commit it, and push. The workflow relies on the + RoleModel **org-level** `HEROKU_IT_SUPPORT_API_KEY` secret and + `HEROKU_IT_SUPPORT_EMAIL` variable — do not create per-repo copies. +4. Verify end-to-end: `gh workflow run deploy-staging.yml` then `gh run watch` the run. + A green run (including its `/up` healthcheck) is the definition of done. + +## Production mode + +Same phases with these differences — ask, don't assume, on every sizing choice: + +- App name defaults to `` (confirm with the user). +- Sentry: reuse the existing project; set `SENTRY_ENVIRONMENT=production`. +- Heroku tiers: ask the user for dyno type (basic/standard-1x/standard-2x/performance), + Postgres plan, and Papertrail plan instead of assuming the staging defaults. +- GitHub environment is `Production`; workflow template is + `templates/deploy-production.yml` (manual `workflow_dispatch` only — production never + auto-deploys on push). + +## Notes + +- Never run destructive Heroku commands (`apps:destroy`, `addons:destroy`, + `pg:reset`) as part of this skill. +- Never read secret material into the model's context: `config/master.key`, + `config/credentials/*.key`, or the output of `heroku config` without `--json | jq` + filtering. When a secret must be set, have the user run the command themselves and + verify only the key's presence afterwards. +- If any phase was already completed on a previous run, say so and continue with the + next phase rather than starting over. diff --git a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-production.yml b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-production.yml new file mode 100644 index 00000000..0241377e --- /dev/null +++ b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-production.yml @@ -0,0 +1,28 @@ +name: Deploy to Heroku Production + +on: + workflow_dispatch: + +concurrency: production-deployment + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: Production + url: ${{ vars.HEROKU_APP_URL }} + steps: + - uses: actions/checkout@v6 + + - name: Install Heroku CLI + run: | + curl https://cli-assets.heroku.com/install.sh | sh + + - uses: akhileshns/heroku-deploy@v3.15.15 + with: + heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} + heroku_app_name: ${{ vars.HEROKU_APP_NAME }} + heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} + healthcheck: '${{ vars.HEROKU_APP_URL }}/up' diff --git a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-staging.yml b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-staging.yml new file mode 100644 index 00000000..eb4a7860 --- /dev/null +++ b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-staging.yml @@ -0,0 +1,31 @@ +name: Deploy to Heroku Staging + +on: + workflow_dispatch: + push: + branches: + - main + +concurrency: staging-deployment + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: Staging + url: ${{ vars.HEROKU_APP_URL }} + steps: + - uses: actions/checkout@v6 + + - name: Install Heroku CLI + run: | + curl https://cli-assets.heroku.com/install.sh | sh + + - uses: akhileshns/heroku-deploy@v3.15.15 + with: + heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} + heroku_app_name: ${{ vars.HEROKU_APP_NAME }} + heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} + healthcheck: '${{ vars.HEROKU_APP_URL }}/up' diff --git a/lib/generators/rolemodel/sentry/sentry_generator.rb b/lib/generators/rolemodel/sentry/sentry_generator.rb index 7dc75fec..a0306b16 100644 --- a/lib/generators/rolemodel/sentry/sentry_generator.rb +++ b/lib/generators/rolemodel/sentry/sentry_generator.rb @@ -15,6 +15,16 @@ def install_gem bundle_command 'add sentry-rails' end + def install_profiler_gem + say 'Adding stackprof gem for Sentry profiling', :green + + # config/initializers/sentry.rb sets profiles_sample_rate; without stackprof + # the SDK logs a warning on every boot and profiling is silently disabled. + # stackprof is a native MRI extension, so restrict it to compatible platforms. + gem 'stackprof', platforms: :ruby + run_bundle + end + def add_ruby_initializer say 'Setting up Sentry for Ruby error reporting', :green diff --git a/lib/generators/rolemodel/simple_form/templates/app/inputs/switch_checkbox_input.rb b/lib/generators/rolemodel/simple_form/templates/app/inputs/switch_checkbox_input.rb index 413c13f4..56d36dbb 100644 --- a/lib/generators/rolemodel/simple_form/templates/app/inputs/switch_checkbox_input.rb +++ b/lib/generators/rolemodel/simple_form/templates/app/inputs/switch_checkbox_input.rb @@ -14,7 +14,7 @@ # <%= f.input :my_field, as: :switch_checkbox, label_after_input: true %> # <%= f.input :my_field, as: :switch_checkbox, wrapper: :switch_wrapper %> class SwitchCheckboxInput < SimpleForm::Inputs::BooleanInput - def input(wrapper_options = nil) + def input(wrapper_options = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength merged_input_options = merge_wrapper_options(input_html_options, wrapper_options) switch_group = template.content_tag(:div, class: "switch #{'switch--small' if options[:small]}") do diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/rails_helper.rb.tt b/lib/generators/rolemodel/testing/rspec/templates/spec/rails_helper.rb.tt index 88b6230d..ba6cc9c6 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/rails_helper.rb.tt +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/rails_helper.rb.tt @@ -70,7 +70,10 @@ RSpec.configure do |config| prep_passed = system 'rails spec:prepare' ENV['ASSET_PRECOMPILE_SUCCESSFUL'] = 'true' - abort "\nYour assets didn't compile. Exiting WITHOUT running any tests. Review the output above to resolve any errors." unless prep_passed + unless prep_passed + abort "\nYour assets didn't compile. Exiting WITHOUT running any tests. " \ + 'Review the output above to resolve any errors.' + end end end end diff --git a/spec/generators/rolemodel/core_setup_generator_spec.rb b/spec/generators/rolemodel/core_setup_generator_spec.rb new file mode 100644 index 00000000..87f39451 --- /dev/null +++ b/spec/generators/rolemodel/core_setup_generator_spec.rb @@ -0,0 +1,35 @@ +RSpec.describe Rolemodel::CoreSetupGenerator do + let(:invocations) { [] } + + def invoke_core_setup(args = []) + generator = described_class.new([], args) + allow(generator).to receive(:generate) { |*invocation| invocations << invocation } + generator.invoke_all + end + + it 'runs the core generators in order' do + invoke_core_setup + + expect(invocations).to eq [ + ['rolemodel:github'], + ['rolemodel:heroku'], + ['rolemodel:readme'], + ['rolemodel:webpack'], + ['rolemodel:sentry'], + ['rolemodel:slim'], + ['rolemodel:optics:all'], + ['rolemodel:testing:all', '--no-js-runner'], + ['rolemodel:simple_form'], + ['rolemodel:linters:all'], + ['rolemodel:ui_components:all'], + ['rolemodel:editors'], + ['rolemodel:lograge'] + ] + end + + it 'passes --js-runner through to the testing generators' do + invoke_core_setup(['--js-runner']) + + expect(invocations).to include ['rolemodel:testing:all', '--js-runner'] + end +end From 160ae7ee7267dd416688ec01595cba8998f4caa4 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 11:57:20 -0400 Subject: [PATCH 05/21] Clean up which items are generated in the core workflow --- .../rolemodel/core_setup/core_setup_generator.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/generators/rolemodel/core_setup/core_setup_generator.rb b/lib/generators/rolemodel/core_setup/core_setup_generator.rb index 78636c46..9f3cab3c 100644 --- a/lib/generators/rolemodel/core_setup/core_setup_generator.rb +++ b/lib/generators/rolemodel/core_setup/core_setup_generator.rb @@ -1,7 +1,5 @@ module Rolemodel class CoreSetupGenerator < ::Rolemodel::GeneratorBase - class_option :js_runner, type: :boolean, default: false, desc: 'Include jasmine-playwright-runner for browser testing' - def run_the_core_generators generate 'rolemodel:github' generate 'rolemodel:heroku' @@ -10,11 +8,11 @@ def run_the_core_generators generate 'rolemodel:sentry' generate 'rolemodel:slim' generate 'rolemodel:optics:all' - generate 'rolemodel:testing:all', options.js_runner? ? '--js-runner' : '--no-js-runner' + generate 'rolemodel:testing:all' generate 'rolemodel:simple_form' generate 'rolemodel:linters:all' - generate 'rolemodel:ui_components:all' - generate 'rolemodel:editors' + generate 'rolemodel:ui_components:flash' + generate 'rolemodel:ui_components:modals' generate 'rolemodel:lograge' end end From 26b824988c249c8db40874317095585da51e6576 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 12:13:38 -0400 Subject: [PATCH 06/21] generator cleanup --- .../rolemodel/heroku/heroku_generator.rb | 15 ++++++ .../templates/skills/deploy-app/SKILL.md | 51 +++++++++++++++---- .../linters/rubocop/rubocop_generator.rb | 6 ++- .../linters/rubocop/templates/.rubocop.yml | 4 +- .../cops/form_error_response.rb | 0 .../{lib => .rubocop}/cops/no_chrome_tag.rb | 0 .../templates/config/initializers/lograge.rb | 2 +- .../templates/support/factory_bot.rb | 2 + .../spec/support/capybara_drivers.rb.tt | 2 + .../templates/spec/support/helpers.rb.tt | 4 +- .../support/helpers/action_cable_helper.rb | 2 + .../support/helpers/test_element_helper.rb | 2 + .../app/helpers/turbo_frame_link_helper.rb | 2 + .../rolemodel/webpack/webpack_generator.rb | 1 + 14 files changed, 77 insertions(+), 16 deletions(-) rename lib/generators/rolemodel/linters/rubocop/templates/{lib => .rubocop}/cops/form_error_response.rb (100%) rename lib/generators/rolemodel/linters/rubocop/templates/{lib => .rubocop}/cops/no_chrome_tag.rb (100%) diff --git a/lib/generators/rolemodel/heroku/heroku_generator.rb b/lib/generators/rolemodel/heroku/heroku_generator.rb index 88b439f7..e9bfa8d2 100644 --- a/lib/generators/rolemodel/heroku/heroku_generator.rb +++ b/lib/generators/rolemodel/heroku/heroku_generator.rb @@ -39,6 +39,21 @@ def pin_ruby_version_for_buildpack inject_into_file 'Gemfile', "\nruby file: '.ruby-version'\n", after: /^source .*$/ end + def use_database_url_in_production + say 'Point the production database at DATABASE_URL for Heroku.', :green + + # Rails' generated production block hardcodes database/username/_DATABASE_PASSWORD, + # none of which exist on Heroku, where the Postgres add-on provides a full DATABASE_URL. + # Replace the whole block with a url-based config. + gsub_file 'config/database.yml', + /^production:\n(?:[ \t]+.*\n?)+/, + <<~YAML + production: + <<: *default + url: <%= ENV["DATABASE_URL"] %> + YAML + end + def force_ssl say 'Require SSL for production environment.', :green diff --git a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md index 781d7e4c..d24d6bd3 100644 --- a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md +++ b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md @@ -2,10 +2,11 @@ name: deploy-app description: > Set up deployment for a RoleModel Rails app after running the rolemodel_rails - core_setup generator. Cleans up the generated Gemfile, verifies the test suite and - RuboCop pass, creates the Sentry project and wires up the DSN, creates and deploys - the Heroku app (buildpacks, dynos, Postgres, Papertrail), and creates the GitHub - deployment environment + deploy workflow. Use when the user says "deploy app", + core_setup generator. Cleans up the generated Gemfile, verifies RuboCop, the test + suite, and a local production smoke test pass, creates the Sentry project and wires + up the DSN, creates and deploys the Heroku app (buildpacks, dynos, Postgres, + Papertrail), and creates the GitHub deployment environment + deploy workflow. Use + when the user says "deploy app", "set up staging", "set up heroku", "set up sentry for deploy", or "set up production deploy". Defaults to staging; pass "production" for the production flow. --- @@ -13,8 +14,9 @@ description: > # Deploy App Completes deployment setup for a RoleModel Rails app in five phases: Gemfile cleanup, -green build (tests + RuboCop), Sentry, Heroku, GitHub. Default mode is **staging**. If -invoked with `production`, follow the [Production mode](#production-mode) differences. +green build (RuboCop + tests + production smoke test), Sentry, Heroku, GitHub. Default +mode is **staging**. If invoked with `production`, follow the +[Production mode](#production-mode) differences. Every step must be **idempotent**: check whether the resource already exists before creating it, and skip or repair rather than failing. Report each phase's outcome as you @@ -37,6 +39,15 @@ fail: 6. Read `config/initializers/sentry.rb` — determine how the DSN is consumed: - reads `ENV['SENTRY_DSN']` → **env-var mode** (RoleModel standard) - reads `Rails.application.credentials...` → **credentials mode** +7. Deploy-readiness (recent `rolemodel-rails` bakes these in; older apps need the manual + fixes noted in each phase). Confirm the generated app already has: + - `terser-webpack-plugin` in `package.json` (webpack config imports it) + - custom cops under `.rubocop/cops/`, not `lib/cops/` (the latter crashes prod boot) + - `config/initializers/lograge.rb` uses `controller.try(:current_user)` + - `config/database.yml` `production:` block uses `url: <%= ENV["DATABASE_URL"] %>` + - a `ruby` directive in the `Gemfile` (so the version reaches `Gemfile.lock`) + - `stackprof` in the `Gemfile` (Sentry profiling) + If any are missing, apply the fix inline and note the app predates the generator update. Set `APP_NAME` = `-staging` (staging) or `` (production), but confirm it with the user before creating anything on Heroku. @@ -58,16 +69,34 @@ this phase if it's already clean): changes — this is an ordering-only cleanup; if the lockfile changed, you altered a dependency and must fix it. -## Phase 2: Green build +## Phase 2: Green build + production smoke test -Do not deploy a broken app. Both checks must pass before continuing to any later phase: +Do not deploy a broken app. This is a **verification gate**: on a current generated app +these should pass with no changes. A failure here usually means a generator regression — +fix the root cause (and flag it for the generator), never paper over it. 1. RuboCop: `bin/rubocop` (fallback: `bundle exec rubocop`). If there are offenses, apply safe autocorrections (`rubocop -a`), then fix the remainder by hand. -2. Test suite: `bin/rspec` (fallback: `bundle exec rspec`). Fix any failures at the +2. Test suite: `bundle exec rspec` (fallback: `bin/rspec`). Fix any failures at the root cause — never skip, pend, or delete tests to get to green. -3. Re-run both until clean, then commit the Gemfile cleanup and any fixes (the deploy - later pushes this branch, so everything must be committed). +3. **Local production smoke test** — this is what catches the boot/asset/DB failures that + only surface under `RAILS_ENV=production` (eager loading, `force_ssl`, DATABASE_URL). + Run against a throwaway local Postgres DB (do not touch development/test data): + ``` + export PROD="RAILS_ENV=production SECRET_KEY_BASE=smoketest \ + DATABASE_URL=postgres://$(whoami)@localhost/_prodsmoke" + env $PROD bundle exec rails db:create + env $PROD bundle exec rails assets:precompile # webpack + asset pipeline + env $PROD RAILS_SERVE_STATIC_FILES=true PORT=3999 bundle exec puma -C config/puma.rb & + sleep 6 + curl -s -o /dev/null -w '%{http_code}\n' -H 'X-Forwarded-Proto: https' \ + http://localhost:3999/up # expect 200 (plain http returns 301 due to force_ssl) + kill %1; env $PROD bundle exec rails db:drop + ``` + `assets:precompile` succeeding and `/up` → 200 is the gate. If `/up` 500s, read the + server log for the root cause before proceeding. +4. Re-run until clean, then commit the Gemfile cleanup and any fixes (the deploy later + pushes this branch, so everything must be committed). ## Phase 3: Sentry project + DSN diff --git a/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb b/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb index c2c2a3ad..dcb99c08 100644 --- a/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb +++ b/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb @@ -16,7 +16,11 @@ def install_rubocop def add_config copy_file '.rubocop.yml', force: true - directory 'lib/cops' + # Custom cops live in .rubocop/cops (loaded via .rubocop.yml's require:), + # not lib/cops — keeping them out of Rails' autoload/eager-load paths so + # they don't crash production boot referencing the dev/test-only RuboCop + # constant. + directory '.rubocop/cops' end end end diff --git a/lib/generators/rolemodel/linters/rubocop/templates/.rubocop.yml b/lib/generators/rolemodel/linters/rubocop/templates/.rubocop.yml index afbbf8ca..d60de63e 100644 --- a/lib/generators/rolemodel/linters/rubocop/templates/.rubocop.yml +++ b/lib/generators/rolemodel/linters/rubocop/templates/.rubocop.yml @@ -1,6 +1,6 @@ require: - - ./lib/cops/form_error_response.rb - - ./lib/cops/no_chrome_tag.rb + - ./.rubocop/cops/form_error_response.rb + - ./.rubocop/cops/no_chrome_tag.rb plugins: - rubocop-rails diff --git a/lib/generators/rolemodel/linters/rubocop/templates/lib/cops/form_error_response.rb b/lib/generators/rolemodel/linters/rubocop/templates/.rubocop/cops/form_error_response.rb similarity index 100% rename from lib/generators/rolemodel/linters/rubocop/templates/lib/cops/form_error_response.rb rename to lib/generators/rolemodel/linters/rubocop/templates/.rubocop/cops/form_error_response.rb diff --git a/lib/generators/rolemodel/linters/rubocop/templates/lib/cops/no_chrome_tag.rb b/lib/generators/rolemodel/linters/rubocop/templates/.rubocop/cops/no_chrome_tag.rb similarity index 100% rename from lib/generators/rolemodel/linters/rubocop/templates/lib/cops/no_chrome_tag.rb rename to lib/generators/rolemodel/linters/rubocop/templates/.rubocop/cops/no_chrome_tag.rb diff --git a/lib/generators/rolemodel/lograge/templates/config/initializers/lograge.rb b/lib/generators/rolemodel/lograge/templates/config/initializers/lograge.rb index a07aaf99..14b66b2b 100644 --- a/lib/generators/rolemodel/lograge/templates/config/initializers/lograge.rb +++ b/lib/generators/rolemodel/lograge/templates/config/initializers/lograge.rb @@ -4,7 +4,7 @@ config.lograge.enabled = true config.lograge.custom_payload do |controller| { - user_id: controller.current_user.try(:id) + user_id: controller.try(:current_user).try(:id) } end config.lograge.custom_options = lambda do |event| diff --git a/lib/generators/rolemodel/testing/factory_bot/templates/support/factory_bot.rb b/lib/generators/rolemodel/testing/factory_bot/templates/support/factory_bot.rb index c7890e49..2e7665cc 100644 --- a/lib/generators/rolemodel/testing/factory_bot/templates/support/factory_bot.rb +++ b/lib/generators/rolemodel/testing/factory_bot/templates/support/factory_bot.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + RSpec.configure do |config| config.include FactoryBot::Syntax::Methods end diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/support/capybara_drivers.rb.tt b/lib/generators/rolemodel/testing/rspec/templates/spec/support/capybara_drivers.rb.tt index 50fd5009..87dcc2bc 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/support/capybara_drivers.rb.tt +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/support/capybara_drivers.rb.tt @@ -1,3 +1,5 @@ +# frozen_string_literal: true + RSpec.configure do |config| Capybara.register_driver :playwright_headless do |app| create_driver(app) diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers.rb.tt b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers.rb.tt index 7d456362..98b2db47 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers.rb.tt +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers.rb.tt @@ -1,4 +1,6 @@ -Dir[Rails.root.join('spec', 'support', 'helpers', '**', '*.rb')].each { |f| require f } +# frozen_string_literal: true + +Rails.root.glob('spec/support/helpers/**/*.rb').each { |f| require f } # this is a place to pull in all your app specific DSL methods. diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/action_cable_helper.rb b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/action_cable_helper.rb index 91f990bb..d62805c0 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/action_cable_helper.rb +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/action_cable_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActionCableHelper def wait_for_stream_connection expect(page).to have_selector('turbo-cable-stream-source[connected]', visible: false) diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/test_element_helper.rb b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/test_element_helper.rb index b1e71808..61e2f008 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/test_element_helper.rb +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/test_element_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TestElementHelper include ActionView::RecordIdentifier diff --git a/lib/generators/rolemodel/ui_components/modals/templates/app/helpers/turbo_frame_link_helper.rb b/lib/generators/rolemodel/ui_components/modals/templates/app/helpers/turbo_frame_link_helper.rb index 4f16d98c..c1b398c6 100644 --- a/lib/generators/rolemodel/ui_components/modals/templates/app/helpers/turbo_frame_link_helper.rb +++ b/lib/generators/rolemodel/ui_components/modals/templates/app/helpers/turbo_frame_link_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TurboFrameLinkHelper def panel_link_to(...) link_to_frame('panel', ...) diff --git a/lib/generators/rolemodel/webpack/webpack_generator.rb b/lib/generators/rolemodel/webpack/webpack_generator.rb index 3fb6de06..b672bbdc 100644 --- a/lib/generators/rolemodel/webpack/webpack_generator.rb +++ b/lib/generators/rolemodel/webpack/webpack_generator.rb @@ -5,6 +5,7 @@ class WebpackGenerator < GeneratorBase DEV_DEPS = %w[ esbuild esbuild-loader + terser-webpack-plugin webpack webpack-cli ] From 0db7d0af4079dcd008f6bdcd1a5369457e9b8e99 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 13:04:44 -0400 Subject: [PATCH 07/21] Remove extra templates --- .../templates/deploy-production.yml | 28 ----------------- .../deploy-app/templates/deploy-staging.yml | 31 ------------------- 2 files changed, 59 deletions(-) delete mode 100644 lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-production.yml delete mode 100644 lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-staging.yml diff --git a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-production.yml b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-production.yml deleted file mode 100644 index 0241377e..00000000 --- a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-production.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Deploy to Heroku Production - -on: - workflow_dispatch: - -concurrency: production-deployment - -jobs: - deploy: - name: Deploy - runs-on: ubuntu-latest - timeout-minutes: 20 - environment: - name: Production - url: ${{ vars.HEROKU_APP_URL }} - steps: - - uses: actions/checkout@v6 - - - name: Install Heroku CLI - run: | - curl https://cli-assets.heroku.com/install.sh | sh - - - uses: akhileshns/heroku-deploy@v3.15.15 - with: - heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} - heroku_app_name: ${{ vars.HEROKU_APP_NAME }} - heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} - healthcheck: '${{ vars.HEROKU_APP_URL }}/up' diff --git a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-staging.yml b/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-staging.yml deleted file mode 100644 index eb4a7860..00000000 --- a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/templates/deploy-staging.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Deploy to Heroku Staging - -on: - workflow_dispatch: - push: - branches: - - main - -concurrency: staging-deployment - -jobs: - deploy: - name: Deploy - runs-on: ubuntu-latest - timeout-minutes: 20 - environment: - name: Staging - url: ${{ vars.HEROKU_APP_URL }} - steps: - - uses: actions/checkout@v6 - - - name: Install Heroku CLI - run: | - curl https://cli-assets.heroku.com/install.sh | sh - - - uses: akhileshns/heroku-deploy@v3.15.15 - with: - heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} - heroku_app_name: ${{ vars.HEROKU_APP_NAME }} - heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} - healthcheck: '${{ vars.HEROKU_APP_URL }}/up' From 610576d9d21e71d7dd3bf235fa23a68f9910b557 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 13:21:37 -0400 Subject: [PATCH 08/21] Fix specs --- spec/cops/form_error_response_spec.rb | 2 +- spec/cops/no_chrome_tag_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/cops/form_error_response_spec.rb b/spec/cops/form_error_response_spec.rb index 37da21e2..7117e60d 100644 --- a/spec/cops/form_error_response_spec.rb +++ b/spec/cops/form_error_response_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' -require 'generators/rolemodel/linters/rubocop/templates/lib/cops/form_error_response' +require 'generators/rolemodel/linters/rubocop/templates/.rubocop/cops/form_error_response' RSpec.describe Cops::FormErrorResponse, :config do include RuboCop::RSpec::ExpectOffense diff --git a/spec/cops/no_chrome_tag_spec.rb b/spec/cops/no_chrome_tag_spec.rb index f11f0335..b5d03f31 100644 --- a/spec/cops/no_chrome_tag_spec.rb +++ b/spec/cops/no_chrome_tag_spec.rb @@ -1,5 +1,5 @@ require 'spec_helper' -require 'generators/rolemodel/linters/rubocop/templates/lib/cops/no_chrome_tag' +require 'generators/rolemodel/linters/rubocop/templates/.rubocop/cops/no_chrome_tag' require 'rubocop' require 'rubocop/rspec/support' From c7b030cd35ecfe83880d238ba5fc2caa74818bf5 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 13:33:33 -0400 Subject: [PATCH 09/21] Fix specs --- .../rolemodel/core_setup_generator_spec.rb | 12 +++--------- .../rolemodel/linters/rubocop_generator_spec.rb | 4 ++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/spec/generators/rolemodel/core_setup_generator_spec.rb b/spec/generators/rolemodel/core_setup_generator_spec.rb index 87f39451..ff38f866 100644 --- a/spec/generators/rolemodel/core_setup_generator_spec.rb +++ b/spec/generators/rolemodel/core_setup_generator_spec.rb @@ -18,18 +18,12 @@ def invoke_core_setup(args = []) ['rolemodel:sentry'], ['rolemodel:slim'], ['rolemodel:optics:all'], - ['rolemodel:testing:all', '--no-js-runner'], + ['rolemodel:testing:all'], ['rolemodel:simple_form'], ['rolemodel:linters:all'], - ['rolemodel:ui_components:all'], - ['rolemodel:editors'], + ['rolemodel:ui_components:flash'], + ['rolemodel:ui_components:modals'], ['rolemodel:lograge'] ] end - - it 'passes --js-runner through to the testing generators' do - invoke_core_setup(['--js-runner']) - - expect(invocations).to include ['rolemodel:testing:all', '--js-runner'] - end end diff --git a/spec/generators/rolemodel/linters/rubocop_generator_spec.rb b/spec/generators/rolemodel/linters/rubocop_generator_spec.rb index d4b1a3c9..9d4453c4 100644 --- a/spec/generators/rolemodel/linters/rubocop_generator_spec.rb +++ b/spec/generators/rolemodel/linters/rubocop_generator_spec.rb @@ -3,7 +3,7 @@ it 'adds the correct helpers' do assert_file '.rubocop.yml' - assert_file 'lib/cops/form_error_response.rb' - assert_file 'lib/cops/no_chrome_tag.rb' + assert_file '.rubocop/cops/form_error_response.rb' + assert_file '.rubocop/cops/no_chrome_tag.rb' end end From 51f371a7477d1575caa3e0518fbfd2d0b9ccf753 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 16:17:51 -0400 Subject: [PATCH 10/21] Just reference the skill from rolemodel rails --- lib/generators/rolemodel/heroku/README.md | 16 +++++----- .../rolemodel/heroku/heroku_generator.rb | 30 +++++++++++-------- .../skills/deploy-app/SKILL.md | 16 +++++----- .../rolemodel/heroku_generator_spec.rb | 8 +++++ 4 files changed, 43 insertions(+), 27 deletions(-) rename lib/{generators/rolemodel/heroku/templates => rolemodel}/skills/deploy-app/SKILL.md (94%) diff --git a/lib/generators/rolemodel/heroku/README.md b/lib/generators/rolemodel/heroku/README.md index 2d652fee..b564d789 100644 --- a/lib/generators/rolemodel/heroku/README.md +++ b/lib/generators/rolemodel/heroku/README.md @@ -4,13 +4,15 @@ * Procfile configured for a server process and a release command to run migrations * Basic app.json preconfigured with a script to initialize the database and common environment variables, addons, and buildpacks -* A `deploy-app` agent skill (`.claude/skills/deploy-app/`, referenced from `AGENTS.md`) that - completes deployment setup: cleans up the generated Gemfile (merges duplicate groups, removes - comments, alphabetizes), verifies the test suite and RuboCop pass, creates the Sentry project - and wires up the DSN, creates and deploys the Heroku staging app (buildpacks, dynos, Postgres, +* A pointer in `AGENTS.md` to the `deploy-app` agent skill, which ships inside the + `rolemodel-rails` gem (`lib/rolemodel/skills/deploy-app/SKILL.md`) rather than being copied + into your repo — it's one-time deployment setup, so nothing skill-related is committed to the + app. The skill cleans up the generated Gemfile (merges duplicate groups, removes comments, + alphabetizes), verifies the test suite and RuboCop pass, creates the Sentry project and wires + up the DSN, creates and deploys the Heroku staging app (buildpacks, dynos, Postgres, Papertrail), and creates the GitHub `Staging` environment with the - `HEROKU_APP_NAME`/`HEROKU_APP_URL` variables the deploy workflow consumes. The skill is LLM-agnostic (Agent Skills format): run `/deploy-app` in - Claude Code, or point any other coding agent at the SKILL.md. Requires the Heroku CLI, the - GitHub CLI, and the Sentry MCP server. + `HEROKU_APP_NAME`/`HEROKU_APP_URL` variables the deploy workflow consumes. The skill is + LLM-agnostic (Agent Skills format): locate it with `bundle show rolemodel-rails` and point any + coding agent at the SKILL.md. Requires the Heroku CLI, the GitHub CLI, and the Sentry MCP server. This is the basic config needed to deploy to Heroku. diff --git a/lib/generators/rolemodel/heroku/heroku_generator.rb b/lib/generators/rolemodel/heroku/heroku_generator.rb index e9bfa8d2..8f44d7c0 100644 --- a/lib/generators/rolemodel/heroku/heroku_generator.rb +++ b/lib/generators/rolemodel/heroku/heroku_generator.rb @@ -15,11 +15,13 @@ def install_procfile template 'Procfile' end - def install_deploy_app_skill - say 'Install deploy-app agent skill', :green - - directory 'skills/deploy-app', '.claude/skills/deploy-app' + def reference_deploy_app_skill + say 'Reference the deploy-app agent skill from rolemodel-rails', :green + # The deploy-app skill is one-time, run-once deployment setup. Rather than + # copying ~200 lines of instructions into every app repo, point the agent at + # the SKILL.md that ships inside the rolemodel-rails gem (already a bundled + # dependency). Nothing skill-related is committed to the generated app. if File.exist?(File.join(destination_root, 'AGENTS.md')) append_to_file 'AGENTS.md', agents_md_skill_entry else @@ -95,15 +97,17 @@ def agents_md_skill_entry ## Agent skills - Reusable, agent-agnostic task instructions live in `.claude/skills/` (Agent Skills - format). If your agent does not auto-discover that directory, read the relevant - `SKILL.md` and follow it directly. - - * `deploy-app` (`.claude/skills/deploy-app/SKILL.md`) — completes deployment setup: - cleans up the generated Gemfile, verifies the test suite and RuboCop pass, creates - the Sentry project and wires up the DSN, creates and deploys the Heroku staging - app, and creates the GitHub `Staging` environment + deploy workflow. Use when - asked to set up staging, Heroku, or deployment. + Reusable, agent-agnostic task instructions (Agent Skills format) ship inside the + `rolemodel-rails` gem — a bundled dependency of this app — rather than being copied + into this repo. Locate the gem's skills directory with + `bundle show rolemodel-rails` (the skills live under `lib/rolemodel/skills/`), then + read the relevant `SKILL.md` and follow it directly. + + * `deploy-app` (`$(bundle show rolemodel-rails)/lib/rolemodel/skills/deploy-app/SKILL.md`) + — one-time deployment setup: cleans up the generated Gemfile, verifies the test + suite and RuboCop pass, creates the Sentry project and wires up the DSN, creates + and deploys the Heroku staging app, and creates the GitHub `Staging` environment + + deploy workflow. Use when asked to set up staging, Heroku, or deployment. MD end end diff --git a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md b/lib/rolemodel/skills/deploy-app/SKILL.md similarity index 94% rename from lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md rename to lib/rolemodel/skills/deploy-app/SKILL.md index d24d6bd3..46f94f95 100644 --- a/lib/generators/rolemodel/heroku/templates/skills/deploy-app/SKILL.md +++ b/lib/rolemodel/skills/deploy-app/SKILL.md @@ -169,10 +169,12 @@ fix the root cause (and flag it for the generator), never paper over it. 2. Set the environment variables: - `gh variable set HEROKU_APP_NAME --env Staging --body "$APP_NAME"` - `gh variable set HEROKU_APP_URL --env Staging --body "$APP_URL"` -3. If `.github/workflows/deploy-staging.yml` doesn't exist, copy it from this skill's - `templates/deploy-staging.yml`, commit it, and push. The workflow relies on the - RoleModel **org-level** `HEROKU_IT_SUPPORT_API_KEY` secret and - `HEROKU_IT_SUPPORT_EMAIL` variable — do not create per-repo copies. +3. Confirm `.github/workflows/deploy-staging.yml` exists (the rolemodel-rails github + generator installs it). If it's missing, the app skipped the github generator — tell + the user to run it (`rails generate rolemodel:github`) rather than hand-writing the + workflow. The workflow relies on the RoleModel **org-level** + `HEROKU_IT_SUPPORT_API_KEY` secret and `HEROKU_IT_SUPPORT_EMAIL` variable — do not + create per-repo copies. 4. Verify end-to-end: `gh workflow run deploy-staging.yml` then `gh run watch` the run. A green run (including its `/up` healthcheck) is the definition of done. @@ -184,9 +186,9 @@ Same phases with these differences — ask, don't assume, on every sizing choice - Sentry: reuse the existing project; set `SENTRY_ENVIRONMENT=production`. - Heroku tiers: ask the user for dyno type (basic/standard-1x/standard-2x/performance), Postgres plan, and Papertrail plan instead of assuming the staging defaults. -- GitHub environment is `Production`; workflow template is - `templates/deploy-production.yml` (manual `workflow_dispatch` only — production never - auto-deploys on push). +- GitHub environment is `Production`; the workflow is + `.github/workflows/deploy-production.yml` (also installed by the github generator; manual + `workflow_dispatch` only — production never auto-deploys on push). ## Notes diff --git a/spec/generators/rolemodel/heroku_generator_spec.rb b/spec/generators/rolemodel/heroku_generator_spec.rb index a8bfd5f6..f6643562 100644 --- a/spec/generators/rolemodel/heroku_generator_spec.rb +++ b/spec/generators/rolemodel/heroku_generator_spec.rb @@ -27,4 +27,12 @@ it 'creates assets.rake task to remove node_modules directory during production build' do assert_file 'lib/tasks/assets.rake' end + + it 'references the deploy-app skill from AGENTS.md instead of copying it into the repo' do + assert_file 'AGENTS.md' do |content| + expect(content).to include('deploy-app') + expect(content).to include('bundle show rolemodel-rails') + end + assert_no_file '.claude/skills/deploy-app/SKILL.md' + end end From ca0ebeb2a8d2691d58726408d62f908dade03009 Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Mon, 6 Jul 2026 16:31:35 -0400 Subject: [PATCH 11/21] Remove semaphore --- .../rolemodel/readme/templates/README.md.erb | 1 - lib/generators/rolemodel/semaphore/README.md | 15 -- lib/generators/rolemodel/semaphore/USAGE | 11 -- .../semaphore/semaphore_generator.rb | 25 --- .../templates/heroku-deployment-commands.sh | 3 - .../templates/production-deploy.yml.erb | 17 --- .../semaphore/templates/semaphore.yml.erb | 143 ------------------ .../templates/staging-deploy.yml.erb | 17 --- 8 files changed, 232 deletions(-) delete mode 100644 lib/generators/rolemodel/semaphore/README.md delete mode 100644 lib/generators/rolemodel/semaphore/USAGE delete mode 100644 lib/generators/rolemodel/semaphore/semaphore_generator.rb delete mode 100644 lib/generators/rolemodel/semaphore/templates/heroku-deployment-commands.sh delete mode 100644 lib/generators/rolemodel/semaphore/templates/production-deploy.yml.erb delete mode 100644 lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb delete mode 100644 lib/generators/rolemodel/semaphore/templates/staging-deploy.yml.erb diff --git a/lib/generators/rolemodel/readme/templates/README.md.erb b/lib/generators/rolemodel/readme/templates/README.md.erb index c061aa97..eade6e41 100644 --- a/lib/generators/rolemodel/readme/templates/README.md.erb +++ b/lib/generators/rolemodel/readme/templates/README.md.erb @@ -85,7 +85,6 @@ When finished with the feature and the code has been reviewed, the commits shoul * [SendGrid](http://sendgrid.com/RoleModel) * [Heroku](http://herokuapp.com) -## [CI](http://semaphoreci.com/RoleModel) ## [Core project presentation](http://docs.google.com) ## [List of contributors](http://github.com/RoleModel) ## [Change log](file://./docs/change_log.md) diff --git a/lib/generators/rolemodel/semaphore/README.md b/lib/generators/rolemodel/semaphore/README.md deleted file mode 100644 index ebcb866e..00000000 --- a/lib/generators/rolemodel/semaphore/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Semaphore Generator - -## What you get - -* semaphore.yml file to run the relevant CI commands -* heroku-deployment-commands.sh file with commands to deploy to heroku -* staging-deploy.yml file with commands to deploy to staging -* production-deploy.yml file with commands to deploy to production - -This is the basic config needed to use semaphore. - -## Note -This config assumes - - `main` is the base branch - - `yarn test` is the command to run JS tests diff --git a/lib/generators/rolemodel/semaphore/USAGE b/lib/generators/rolemodel/semaphore/USAGE deleted file mode 100644 index 5382907f..00000000 --- a/lib/generators/rolemodel/semaphore/USAGE +++ /dev/null @@ -1,11 +0,0 @@ -Description: - Sets up our standard semaphore configuration - -Example: - rails generate rolemodel:semaphore - - This will create: - semaphore.yml - heroku-deployment-commands.sh - staging-deploy.yml - production-deploy.yml diff --git a/lib/generators/rolemodel/semaphore/semaphore_generator.rb b/lib/generators/rolemodel/semaphore/semaphore_generator.rb deleted file mode 100644 index 61f62e12..00000000 --- a/lib/generators/rolemodel/semaphore/semaphore_generator.rb +++ /dev/null @@ -1,25 +0,0 @@ -module Rolemodel - class SemaphoreGenerator < GeneratorBase - source_root File.expand_path('templates', __dir__) - - def create_base_semaphore_config - @project_name = Rails.application.class.try(:parent_name) || Rails.application.class.module_parent_name - template 'semaphore.yml.erb', '.semaphore/semaphore.yml' - - if yes?('Does your project have JavaScript tests and/or eslint?') - uncomment_lines('.semaphore/semaphore.yml', '- yarn test') - uncomment_lines('.semaphore/semaphore.yml', '- yarn run eslint') - end - end - - def create_deplyment_commands - default_heroku_prefix = (Rails.application.class.try(:parent_name) || Rails.application.class.module_parent_name).underscore.dasherize - - @heroku_prefix = ask('Enter the heroku project prefix', default: default_heroku_prefix) - - template 'heroku-deployment-commands.sh', '.semaphore/heroku-deployment-commands.sh' - template 'staging-deploy.yml.erb', '.semaphore/staging-deploy.yml' - template 'production-deploy.yml.erb', '.semaphore/production-deploy.yml' - end - end -end diff --git a/lib/generators/rolemodel/semaphore/templates/heroku-deployment-commands.sh b/lib/generators/rolemodel/semaphore/templates/heroku-deployment-commands.sh deleted file mode 100644 index b23afa98..00000000 --- a/lib/generators/rolemodel/semaphore/templates/heroku-deployment-commands.sh +++ /dev/null @@ -1,3 +0,0 @@ -checkout --use-cache -heroku git:remote -a $HEROKU_APP_NAME -git push heroku -f $SEMAPHORE_GIT_BRANCH:main diff --git a/lib/generators/rolemodel/semaphore/templates/production-deploy.yml.erb b/lib/generators/rolemodel/semaphore/templates/production-deploy.yml.erb deleted file mode 100644 index 42bddf27..00000000 --- a/lib/generators/rolemodel/semaphore/templates/production-deploy.yml.erb +++ /dev/null @@ -1,17 +0,0 @@ -version: v1.0 -name: Deploy to Production -agent: - machine: - type: e1-standard-2 - os_image: ubuntu1804 -blocks: - - name: Deploy - task: - secrets: - - name: heroku_http_auth - env_vars: - - name: HEROKU_APP_NAME - value: <%= "#{@heroku_prefix}-production" %> - jobs: - - name: 'Push code to Production' - commands_file: heroku-deployment-commands.sh diff --git a/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb b/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb deleted file mode 100644 index e2577ccc..00000000 --- a/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb +++ /dev/null @@ -1,143 +0,0 @@ -version: v1.0 -name: <%= @project_name %> -agent: - machine: - type: e1-standard-2 - os_image: ubuntu2004 -auto_cancel: - running: - when: branch != 'main' -global_job_config: - env_vars: - - name: RAILS_ENV - value: test - - name: DATABASE_URL - value: 'postgres://postgres:@0.0.0.0/<%= Rails.application.config.database_configuration['test']['database'] %>' - - name: GOOGLE_CHROME_BIN - value: 'google-chrome' - # https://github.com/renderedtext/test-boosters#rspec-booster - - name: TB_RSPEC_OPTIONS - value: '--format RspecJunitFormatter --out tmp/test_results/rspec_junit.xml --format documentation' - secrets: - - name: rmsbackup_ssh_private_key - prologue: - commands: - # Add keys - - ssh-keyscan -H github.com >> ~/.ssh/known_hosts - - chmod 600 ~/.ssh/id_rsa_rmsbackup_ssh_private_key - - ssh-add ~/.ssh/id_rsa_rmsbackup_ssh_private_key - - - checkout - - # Ruby setup - - sem-service start postgres 14 - - sem-version ruby $(cat .ruby-version) - - gem install bundler --no-document - - bundle config set deployment 'true' - - bundle config set path 'vendor/bundle' - - # Node setup - - sem-version node $(cat .node-version) - - corepack enable - - # Cache setup - - cache restore gems-$(checksum .ruby-version)-$(checksum Gemfile.lock) - - cache restore yarn-cache-$(checksum .node-version)-$(checksum yarn.lock) - - cache restore yarn-node-modules-$(checksum .node-version)-$(checksum yarn.lock) -blocks: - - name: Build - dependencies: [] - task: - jobs: - - name: Install Dependencies - commands: - - bundle check || bundle install - - yarn install --immutable - epilogue: - commands: - - cache store gems-$(checksum .ruby-version)-$(checksum Gemfile.lock) vendor/bundle - - cache store yarn-cache-$(checksum .node-version)-$(checksum yarn.lock) .yarn/cache - - cache store yarn-node-modules-$(checksum .node-version)-$(checksum yarn.lock) node_modules - - name: Ruby/JS/Linting - execution_time_limit: - minutes: 20 - dependencies: - - Build - task: - jobs: - - name: Ruby Test/JS Tests/Audit/Linting - commands: - - bundle exec rails db:setup db:test:prepare - - bundle exec rspec --exclude-pattern "spec/system/**/*_spec.rb" --format RspecJunitFormatter --out tmp/test_results/rspec_junit.xml --format documentation - - bundle exec bundle-audit update - - bundle exec bundle-audit check - # - bundle exec rubocop --fail-level warning --display-only-fail-level-offenses - # - yarn test - # - yarn run eslint - epilogue: - always: - commands: - - test-results publish tmp/test_results - on_fail: - commands: - - artifact push job log/test.log - - name: Assets Compile - dependencies: - - Build - execution_time_limit: - minutes: 20 - task: - jobs: - - name: Webpack Compile and Store Assets - commands: - - bundle check || bundle install --without legacy_data_migration - - yarn install --immutable - - yarn build - - cache store assets-public-$SEMAPHORE_WORKFLOW_ID public - - name: System Tests - dependencies: - - Assets Compile - execution_time_limit: - minutes: 20 - task: - agent: - machine: - type: e1-standard-2 - os_image: ubuntu2004 - env_vars: - - name: TEST_BOOSTERS_RSPEC_TEST_FILE_PATTERN - value: spec/system/**/*_spec.rb - prologue: - commands: - - gem install semaphore_test_boosters - - bundle exec rails db:setup db:test:prepare - - cache restore assets-public-$SEMAPHORE_WORKFLOW_ID - jobs: - - name: Ruby System Tests - parallelism: 2 - commands: - - rspec_booster --job $SEMAPHORE_JOB_INDEX/$SEMAPHORE_JOB_COUNT - epilogue: - always: - commands: - - test-results publish tmp/test_results - - cache delete assets-public-$SEMAPHORE_WORKFLOW_ID - on_fail: - commands: - - artifact push job log/test.log - - artifact push workflow tmp/capybara -after_pipeline: - task: - jobs: - - name: Publish Results - commands: - - test-results gen-pipeline-report -promotions: - - name: Deploy Staging - pipeline_file: staging-deploy.yml - auto_promote_on: - - result: passed - branch: - - main - - name: Deploy Production - pipeline_file: production-deploy.yml diff --git a/lib/generators/rolemodel/semaphore/templates/staging-deploy.yml.erb b/lib/generators/rolemodel/semaphore/templates/staging-deploy.yml.erb deleted file mode 100644 index b5545ffe..00000000 --- a/lib/generators/rolemodel/semaphore/templates/staging-deploy.yml.erb +++ /dev/null @@ -1,17 +0,0 @@ -version: v1.0 -name: Deploy to Staging -agent: - machine: - type: e1-standard-2 - os_image: ubuntu1804 -blocks: - - name: Deploy - task: - secrets: - - name: heroku_http_auth - env_vars: - - name: HEROKU_APP_NAME - value: <%= "#{@heroku_prefix}-staging" %> - jobs: - - name: 'Push code to staging' - commands_file: heroku-deployment-commands.sh From 64f185baa46cd854e659370115253b736aa85a1c Mon Sep 17 00:00:00 2001 From: Mark Kraemer Date: Tue, 7 Jul 2026 12:21:28 -0400 Subject: [PATCH 12/21] Address PR #198 review feedback Apply the reviewer's concrete suggestions and reduce cross-generator coupling: - deploy workflows: support deploying a specific SHA/branch via workflow_dispatch input; staging pushes now trigger on the `staging` branch - core_setup README/USAGE: tighten the UI Components / feature descriptions - simple_form: replace the interactive tailored_select prompt with a --tailored_select class option that delegates to the tailored_select generator - tailored_select: own the SimpleForm input template and install it only when SimpleForm is present in the app - webpack: own the Sentry webpack wiring via a --sentry option; core_setup passes it. The wiring itself is a single shared GeneratorBase helper reused by the standalone sentry generator, so there's one source of truth - webpack: drop terser-webpack-plugin and rely on webpack 5's built-in minimizer (the '...' placeholder), keeping CssMinimizerPlugin - sentry: guard set_sentry_user so it's a no-op without an auth generator, removing the hard Devise dependency in fresh core_setup apps Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/generators/rolemodel/core_setup/README.md | 2 +- lib/generators/rolemodel/core_setup/USAGE | 5 +-- .../core_setup/core_setup_generator.rb | 2 +- .../templates/workflows/deploy-production.yml | 6 +++ .../templates/workflows/deploy-staging.yml | 12 +++-- .../rolemodel/sentry/sentry_generator.rb | 43 +++--------------- lib/generators/rolemodel/simple_form/USAGE | 5 +-- .../simple_form/simple_form_generator.rb | 11 +++-- .../rolemodel/tailored_select/USAGE | 3 +- .../tailored_select_generator.rb | 14 ++++++ .../app/inputs}/tailored_select_input.rb | 0 .../webpack/templates/webpack.config.js | 4 +- .../rolemodel/webpack/webpack_generator.rb | 12 ++++- lib/rolemodel/generator_base.rb | 44 +++++++++++++++++++ .../rolemodel/core_setup_generator_spec.rb | 2 +- .../rolemodel/sentry_generator_spec.rb | 3 +- .../rolemodel/simple_form_generator_spec.rb | 34 +++++++------- .../tailored_select_generator_spec.rb | 17 ++++++- .../rolemodel/webpack_generator_spec.rb | 18 ++++++++ 19 files changed, 161 insertions(+), 76 deletions(-) rename lib/generators/rolemodel/{simple_form/templates/optional => tailored_select/templates/app/inputs}/tailored_select_input.rb (100%) diff --git a/lib/generators/rolemodel/core_setup/README.md b/lib/generators/rolemodel/core_setup/README.md index 209bde21..6b7c3116 100644 --- a/lib/generators/rolemodel/core_setup/README.md +++ b/lib/generators/rolemodel/core_setup/README.md @@ -22,6 +22,6 @@ app you can push straight to Heroku right after generation. (pass `--js-runner` to include jasmine-playwright-runner) - [SimpleForm](../simple_form) — SimpleForm with our configuration - [Linters](../linters) — Rubocop and ESLint - - [UI Components](../ui_components) — flash, modals, navbar + - [UI Components](../ui_components) — flash, the modal pattern, & Turbo 8 support - [Editors](../editors) — EditorConfig and recommended VSCode extensions - [Lograge](../lograge) — condensed request logging diff --git a/lib/generators/rolemodel/core_setup/USAGE b/lib/generators/rolemodel/core_setup/USAGE index 0a3bc4dd..386c64f6 100644 --- a/lib/generators/rolemodel/core_setup/USAGE +++ b/lib/generators/rolemodel/core_setup/USAGE @@ -2,9 +2,8 @@ Description: Runs the core generators every new Rails app should have Sets up GitHub config, Heroku deployment, README, Webpack, Sentry, Slim, - Optics, testing (RSpec & friends), SimpleForm, linters, UI components, - editor config, and Lograge. The result is a new Rails app that is ready - to push straight to Heroku. + Optics, testing (RSpec & friends), SimpleForm, linters, Turbo 8+ support, + and Lograge. The result is an app that is ready to push directly to Heroku. Pass --js-runner to also include jasmine-playwright-runner for browser JS testing. diff --git a/lib/generators/rolemodel/core_setup/core_setup_generator.rb b/lib/generators/rolemodel/core_setup/core_setup_generator.rb index 9f3cab3c..5ab81f9a 100644 --- a/lib/generators/rolemodel/core_setup/core_setup_generator.rb +++ b/lib/generators/rolemodel/core_setup/core_setup_generator.rb @@ -4,7 +4,7 @@ def run_the_core_generators generate 'rolemodel:github' generate 'rolemodel:heroku' generate 'rolemodel:readme' - generate 'rolemodel:webpack' + generate 'rolemodel:webpack --sentry' generate 'rolemodel:sentry' generate 'rolemodel:slim' generate 'rolemodel:optics:all' diff --git a/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml b/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml index 0241377e..18784b34 100644 --- a/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml +++ b/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml @@ -2,6 +2,11 @@ name: Deploy to Heroku Production on: workflow_dispatch: + inputs: + sha: + description: 'Specific SHA or Branch name (optional)' + required: false + type: string concurrency: production-deployment @@ -25,4 +30,5 @@ jobs: heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} heroku_app_name: ${{ vars.HEROKU_APP_NAME }} heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} + branch: ${{ inputs.sha || 'HEAD' }} healthcheck: '${{ vars.HEROKU_APP_URL }}/up' diff --git a/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml b/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml index eb4a7860..515b464e 100644 --- a/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml +++ b/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml @@ -2,9 +2,12 @@ name: Deploy to Heroku Staging on: workflow_dispatch: - push: - branches: - - main + inputs: + sha: + description: 'Specific SHA or Branch name (optional)' + required: false + type: string + push: { branches: [ staging ] } concurrency: staging-deployment @@ -28,4 +31,5 @@ jobs: heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} heroku_app_name: ${{ vars.HEROKU_APP_NAME }} heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} - healthcheck: '${{ vars.HEROKU_APP_URL }}/up' + branch: ${{ inputs.sha || 'HEAD' }} + healthcheck: "${{ vars.HEROKU_APP_URL }}/up" diff --git a/lib/generators/rolemodel/sentry/sentry_generator.rb b/lib/generators/rolemodel/sentry/sentry_generator.rb index a0306b16..dbdf5e2e 100644 --- a/lib/generators/rolemodel/sentry/sentry_generator.rb +++ b/lib/generators/rolemodel/sentry/sentry_generator.rb @@ -35,14 +35,17 @@ def add_user_context say 'Adding Sentry user context to ApplicationController', :green inject_into_class 'app/controllers/application_controller.rb', 'ApplicationController', - " before_action :set_sentry_user, if: :user_signed_in?\n" + " before_action :set_sentry_user\n" inject_into_file 'app/controllers/application_controller.rb', before: /^end\b/ do <<-RUBY private + # Guarded so it works whether or not an authentication generator has been run. def set_sentry_user + return unless respond_to?(:current_user) && current_user + Sentry.set_user(id: current_user.id) end RUBY @@ -65,36 +68,10 @@ def add_js_initializer JS end + # For standalone `rolemodel:sentry` runs; when run via core_setup the webpack + # generator has already wired this (and this call is a no-op). def configure_webpack - say 'Wiring Sentry into webpack.config.js', :green - - inject_into_file 'webpack.config.js', - "import { sentryWebpackPlugin } from '@sentry/webpack-plugin'\n", - after: "import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'\n" - - inject_into_file 'webpack.config.js', after: "'process.env.RAILS_ENV': JSON.stringify(process.env.RAILS_ENV),\n" do - <<-JS - 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), - 'process.env.SENTRY_ENVIRONMENT': JSON.stringify(process.env.SENTRY_ENVIRONMENT), - 'process.env.SENTRY_AUTH_TOKEN': JSON.stringify(process.env.SENTRY_AUTH_TOKEN), - JS - end - - gsub_file 'webpack.config.js', " })\n ].filter(Boolean)", <<-JS.chomp - }), - - // Upload source maps to Sentry in production for easier debugging - mode === 'production' && - !process.env.CI && - sentryWebpackPlugin({ - authToken: process.env.SENTRY_AUTH_TOKEN, - org: 'rolemodel-software', - project: '#{app_name}', - telemetry: false, - applicationKey: 'app-frontend' - }) - ].filter(Boolean) - JS + wire_sentry_into_webpack end def finishing_notes @@ -106,11 +83,5 @@ def finishing_notes *** Set the SENTRY_DSN, SENTRY_ENVIRONMENT, and SENTRY_AUTH_TOKEN environment variables. NOTES end - - private - - def app_name - Rails.application.class.module_parent_name.underscore - end end end diff --git a/lib/generators/rolemodel/simple_form/USAGE b/lib/generators/rolemodel/simple_form/USAGE index 326f9f3f..a4f0750a 100644 --- a/lib/generators/rolemodel/simple_form/USAGE +++ b/lib/generators/rolemodel/simple_form/USAGE @@ -10,6 +10,5 @@ Example: simple_form input wrapper classes simple_form template file - All standard custom inputs are included by default. You will be - prompted to confirm whether to include the tailored_select custom - input, since not every project uses the tailored-select web component. + Pass --tailored_select to also install the experimental tailored_select + component input (via the rolemodel:tailored_select generator). diff --git a/lib/generators/rolemodel/simple_form/simple_form_generator.rb b/lib/generators/rolemodel/simple_form/simple_form_generator.rb index 5b184790..a27b637b 100644 --- a/lib/generators/rolemodel/simple_form/simple_form_generator.rb +++ b/lib/generators/rolemodel/simple_form/simple_form_generator.rb @@ -4,6 +4,9 @@ module Rolemodel class SimpleFormGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + class_option :tailored_select, type: :boolean, default: false, + desc: 'Install the tailored_select experimental component input' + def add_gem Bundler.with_unbundled_env do bundle_command 'add simple_form' @@ -17,10 +20,12 @@ def add_files copy_file 'config/locales/simple_form.en.yml' end - def add_tailored_select_input - return unless yes?('Would you like to include the tailored_select custom input?') + def install_tailored_select + return unless options.tailored_select? - copy_file 'optional/tailored_select_input.rb', 'app/inputs/tailored_select_input.rb' + # The tailored_select generator owns the input template and installs it + # here because simple_form is now present. + generate 'rolemodel:tailored_select' end end end diff --git a/lib/generators/rolemodel/tailored_select/USAGE b/lib/generators/rolemodel/tailored_select/USAGE index 57754b53..c469091f 100644 --- a/lib/generators/rolemodel/tailored_select/USAGE +++ b/lib/generators/rolemodel/tailored_select/USAGE @@ -1,5 +1,6 @@ Description: - runs the tailored select generator + Installs the @rolemodel/tailored-select package. If the app is using + SimpleForm, it also installs the matching tailored_select SimpleForm input. Example: rails generate rolemodel:tailored_select diff --git a/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb b/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb index a2859382..a061b5b9 100644 --- a/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb +++ b/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb @@ -10,5 +10,19 @@ def add_tailored_select_package ensure_yarn run 'yarn add @rolemodel/tailored-select' end + + def add_simple_form_input + return unless simple_form? + + say 'Installing the Tailored Select SimpleForm input', :green + + copy_file 'app/inputs/tailored_select_input.rb' + end + + private + + def simple_form? + File.exist?(File.expand_path('config/initializers/simple_form.rb', destination_root)) + end end end diff --git a/lib/generators/rolemodel/simple_form/templates/optional/tailored_select_input.rb b/lib/generators/rolemodel/tailored_select/templates/app/inputs/tailored_select_input.rb similarity index 100% rename from lib/generators/rolemodel/simple_form/templates/optional/tailored_select_input.rb rename to lib/generators/rolemodel/tailored_select/templates/app/inputs/tailored_select_input.rb diff --git a/lib/generators/rolemodel/webpack/templates/webpack.config.js b/lib/generators/rolemodel/webpack/templates/webpack.config.js index 41bea13e..af66c4c7 100644 --- a/lib/generators/rolemodel/webpack/templates/webpack.config.js +++ b/lib/generators/rolemodel/webpack/templates/webpack.config.js @@ -1,6 +1,5 @@ import path from 'path' import webpack from 'webpack' -import TerserPlugin from 'terser-webpack-plugin' import MiniCssExtractPlugin from 'mini-css-extract-plugin' import CssMinimizerPlugin from 'css-minimizer-webpack-plugin' @@ -72,7 +71,8 @@ export default { optimization: { minimize: mode === 'production', minimizer: [ - new TerserPlugin(), + // '...' keeps webpack 5's built-in JS minimizer (terser) + '...', new CssMinimizerPlugin() ] }, diff --git a/lib/generators/rolemodel/webpack/webpack_generator.rb b/lib/generators/rolemodel/webpack/webpack_generator.rb index b672bbdc..9117f567 100644 --- a/lib/generators/rolemodel/webpack/webpack_generator.rb +++ b/lib/generators/rolemodel/webpack/webpack_generator.rb @@ -2,10 +2,12 @@ module Rolemodel class WebpackGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + class_option :sentry, type: :boolean, default: false, + desc: 'Wire the Sentry webpack plugin into webpack.config.js' + DEV_DEPS = %w[ esbuild esbuild-loader - terser-webpack-plugin webpack webpack-cli ] @@ -68,5 +70,13 @@ def add_webpack_config copy_file 'postcss.config.cjs', force: true copy_file 'webpack.config.js', force: true end + + # Wiring the Sentry webpack plugin lives here (rather than in the Sentry + # generator) so the whole webpack.config.js is owned by one generator. + def configure_sentry + return unless options.sentry? + + wire_sentry_into_webpack + end end end diff --git a/lib/rolemodel/generator_base.rb b/lib/rolemodel/generator_base.rb index e3fbc1fb..972f6f6f 100644 --- a/lib/rolemodel/generator_base.rb +++ b/lib/rolemodel/generator_base.rb @@ -50,5 +50,49 @@ def ignore_yarn_install_state append_to_file '.gitignore', "\n/.yarn/install-state.gz\n" end + + # Single source of truth for wiring the Sentry webpack plugin into + # webpack.config.js. Shared by the webpack generator (which owns the config + # file) and the sentry generator (for standalone runs). Idempotent and a + # no-op if there's no webpack.config.js or it's already wired. + def wire_sentry_into_webpack + config = File.expand_path('webpack.config.js', destination_root) + return unless File.exist?(config) + return if File.read(config).include?('@sentry/webpack-plugin') + + say 'Wiring Sentry into webpack.config.js', :green + + inject_into_file 'webpack.config.js', + "import { sentryWebpackPlugin } from '@sentry/webpack-plugin'\n", + after: "import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'\n" + + inject_into_file 'webpack.config.js', after: "'process.env.RAILS_ENV': JSON.stringify(process.env.RAILS_ENV),\n" do + <<-JS + 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), + 'process.env.SENTRY_ENVIRONMENT': JSON.stringify(process.env.SENTRY_ENVIRONMENT), + 'process.env.SENTRY_AUTH_TOKEN': JSON.stringify(process.env.SENTRY_AUTH_TOKEN), + JS + end + + gsub_file 'webpack.config.js', " })\n ].filter(Boolean)", <<-JS.chomp + }), + + // Upload source maps to Sentry in production for easier debugging + mode === 'production' && + !process.env.CI && + sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: 'rolemodel-software', + project: '#{sentry_app_name}', + telemetry: false, + applicationKey: 'app-frontend' + }) + ].filter(Boolean) + JS + end + + def sentry_app_name + Rails.application.class.module_parent_name.underscore + end end end diff --git a/spec/generators/rolemodel/core_setup_generator_spec.rb b/spec/generators/rolemodel/core_setup_generator_spec.rb index ff38f866..477814c1 100644 --- a/spec/generators/rolemodel/core_setup_generator_spec.rb +++ b/spec/generators/rolemodel/core_setup_generator_spec.rb @@ -14,7 +14,7 @@ def invoke_core_setup(args = []) ['rolemodel:github'], ['rolemodel:heroku'], ['rolemodel:readme'], - ['rolemodel:webpack'], + ['rolemodel:webpack --sentry'], ['rolemodel:sentry'], ['rolemodel:slim'], ['rolemodel:optics:all'], diff --git a/spec/generators/rolemodel/sentry_generator_spec.rb b/spec/generators/rolemodel/sentry_generator_spec.rb index 68b723ce..7b02b07e 100644 --- a/spec/generators/rolemodel/sentry_generator_spec.rb +++ b/spec/generators/rolemodel/sentry_generator_spec.rb @@ -16,7 +16,8 @@ end assert_file 'app/controllers/application_controller.rb' do |content| - expect(content).to include('before_action :set_sentry_user, if: :user_signed_in?') + expect(content).to include('before_action :set_sentry_user') + expect(content).to include('respond_to?(:current_user) && current_user') expect(content).to include('Sentry.set_user(id: current_user.id)') end end diff --git a/spec/generators/rolemodel/simple_form_generator_spec.rb b/spec/generators/rolemodel/simple_form_generator_spec.rb index 0e93dba9..b549c60a 100644 --- a/spec/generators/rolemodel/simple_form_generator_spec.rb +++ b/spec/generators/rolemodel/simple_form_generator_spec.rb @@ -1,13 +1,11 @@ RSpec.describe Rolemodel::SimpleFormGenerator, type: :generator do it 'generates a simple form initializer' do - allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(false) run_generator_against_test_app assert_file 'config/initializers/simple_form.rb' end it 'generates the default custom input files' do - allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(false) run_generator_against_test_app assert_file 'app/inputs/collection_check_boxes_input.rb' @@ -18,7 +16,6 @@ end it 'generates slim scaffold templates for simple form' do - allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(false) run_generator_against_test_app assert_file 'lib/templates/slim/scaffold/_form.html.slim' do |content| @@ -27,25 +24,28 @@ end end - context 'when the tailored_select input is declined' do - before do - allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(false) - run_generator_against_test_app - end + context 'the --tailored_select option' do + let(:invocations) { [] } - it 'does not generate the tailored_select input' do - assert_no_file 'app/inputs/tailored_select_input.rb' + def invoke_simple_form(args = []) + generator = described_class.new([], args) + allow(generator).to receive(:generate) { |*invocation| invocations << invocation } + # Stub the parts that touch the filesystem/network; we only care about + # whether the tailored_select generator is delegated to. + %i[add_gem add_files].each { |step| allow(generator).to receive(step) } + generator.invoke_all end - end - context 'when the tailored_select input is confirmed' do - before do - allow_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return(true) - run_generator_against_test_app + it 'delegates to the tailored_select generator when passed' do + invoke_simple_form(['--tailored_select']) + + expect(invocations).to include(['rolemodel:tailored_select']) end - it 'generates the tailored_select input' do - assert_file 'app/inputs/tailored_select_input.rb' + it 'does not delegate to the tailored_select generator by default' do + invoke_simple_form + + expect(invocations).not_to include(['rolemodel:tailored_select']) end end end diff --git a/spec/generators/rolemodel/tailored_select_generator_spec.rb b/spec/generators/rolemodel/tailored_select_generator_spec.rb index 777804ec..6644b888 100644 --- a/spec/generators/rolemodel/tailored_select_generator_spec.rb +++ b/spec/generators/rolemodel/tailored_select_generator_spec.rb @@ -1,9 +1,22 @@ RSpec.describe Rolemodel::TailoredSelectGenerator, type: :generator do - before { run_generator_against_test_app } - it 'adds tailored select to package.json' do + run_generator_against_test_app + assert_file 'package.json' do |content| expect(content).to include('"@rolemodel/tailored-select":') end end + + it 'does not install the SimpleForm input when SimpleForm is absent' do + run_generator_against_test_app + + assert_no_file 'app/inputs/tailored_select_input.rb' + end + + it 'installs the SimpleForm input when SimpleForm is present' do + run_generator_against_test_app generator: ::Rolemodel::SimpleFormGenerator + run_generator_against_test_app + + assert_file 'app/inputs/tailored_select_input.rb' + end end diff --git a/spec/generators/rolemodel/webpack_generator_spec.rb b/spec/generators/rolemodel/webpack_generator_spec.rb index 48a083ef..7e19ad1e 100644 --- a/spec/generators/rolemodel/webpack_generator_spec.rb +++ b/spec/generators/rolemodel/webpack_generator_spec.rb @@ -31,4 +31,22 @@ expect(content).to include('/.yarn/install-state.gz') end end + + it 'does not wire Sentry into webpack.config.js by default' do + assert_file 'webpack.config.js' do |content| + expect(content).not_to include('@sentry/webpack-plugin') + end + end + + context 'with the --sentry option' do + before { run_generator_against_test_app(['--sentry']) } + + it 'wires the Sentry plugin into webpack.config.js' do + assert_file 'webpack.config.js' do |content| + expect(content).to include("import { sentryWebpackPlugin } from '@sentry/webpack-plugin'") + expect(content).to include('sentryWebpackPlugin({') + expect(content).to include('].filter(Boolean)') + end + end + end end From e8f5c0afd40bf81692c75adfcb691dec4e8d5cb8 Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 12:56:57 -0500 Subject: [PATCH 13/21] minor clean up --- .../rolemodel/sentry/templates/config/initializers/sentry.rb | 4 +--- lib/rolemodel/generator_base.rb | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb b/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb index 954034a9..b85dad7f 100644 --- a/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb +++ b/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb @@ -64,7 +64,5 @@ end config.rails.register_error_subscriber = Rails.env.production? - config.rails.structured_logging.subscribers = { - action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber, - } + config.rails.structured_logging.subscribers = config.rails.structured_logging.subscribers.slice(:action_controller) end diff --git a/lib/rolemodel/generator_base.rb b/lib/rolemodel/generator_base.rb index 972f6f6f..9dc50178 100644 --- a/lib/rolemodel/generator_base.rb +++ b/lib/rolemodel/generator_base.rb @@ -83,7 +83,7 @@ def wire_sentry_into_webpack sentryWebpackPlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, org: 'rolemodel-software', - project: '#{sentry_app_name}', + project: '#{Rails.application.name}', telemetry: false, applicationKey: 'app-frontend' }) From 7e956d2913a2a820a0b401034c3081f096499079 Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 15:52:28 -0500 Subject: [PATCH 14/21] test: pin the Rails resolution chain the generator registry relies on Verifies against Rails 8.1.2: config.generators routing into Rails::Generators.options, boolean hook_for default resolution at class definition (including generator_name > base_name precedence), hook target namespace lookup priority, declaration-position execution order, and that the engine's eager-require path defines no hook-declaring generators (engine generators blocks run before Rails::Generators.configure!). --- spec/rolemodel/registry_resolution_spec.rb | 139 +++++++++++++++++++++ spec/support/helpers/generator_config.rb | 58 +++++++++ 2 files changed, 197 insertions(+) create mode 100644 spec/rolemodel/registry_resolution_spec.rb create mode 100644 spec/support/helpers/generator_config.rb diff --git a/spec/rolemodel/registry_resolution_spec.rb b/spec/rolemodel/registry_resolution_spec.rb new file mode 100644 index 00000000..9898f63c --- /dev/null +++ b/spec/rolemodel/registry_resolution_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' + +# Executable verification of the framework mechanics the generator registry +# design rests on. Each example pins a fact about Rails' resolution chain; +# if one fails after a Rails upgrade, revisit the registry design before +# debugging anything built on top of it. +REGISTRY_RESOLUTION_ORDER = [] + +RSpec.describe 'Registry resolution chain', :generator_config do + after { REGISTRY_RESOLUTION_ORDER.clear } + + describe 'config.generators -> Rails::Generators.options' do + it 'routes g.rolemodel entries into the :rolemodel namespace' do + apply_generator_config { |g| g.rolemodel registry_probe: true } + + expect(Rails::Generators.options[:rolemodel][:registry_probe]).to be true + end + end + + describe 'boolean hook_for default resolution at class definition' do + it 'resolves the default from the rolemodel namespace' do + apply_generator_config { |g| g.rolemodel registry_probe: true } + + class Rolemodel::ProbeAlphaGenerator < ::Rails::Generators::Base + hook_for :registry_probe, type: :boolean + end + + expect(Rolemodel::ProbeAlphaGenerator.class_options[:registry_probe].default).to be true + ensure + remove_generators Rolemodel::ProbeAlphaGenerator + end + + it 'prefers generator_name config over base_name config' do + apply_generator_config do |g| + g.rolemodel registry_probe: true + g.probe_beta registry_probe: false + end + + class Rolemodel::ProbeBetaGenerator < ::Rails::Generators::Base + hook_for :registry_probe, type: :boolean + end + + expect(Rolemodel::ProbeBetaGenerator.class_options[:registry_probe].default).to be false + ensure + remove_generators Rolemodel::ProbeBetaGenerator + end + + it 'falls back to the class default when no config exists (spec-harness condition)' do + class Rolemodel::ProbeGammaGenerator < ::Rails::Generators::Base + hook_for :registry_probe, type: :boolean + end + + expect(Rolemodel::ProbeGammaGenerator.class_options[:registry_probe].default).to be_falsey + ensure + remove_generators Rolemodel::ProbeGammaGenerator + end + end + + describe 'boolean hook target lookup' do + it 'resolves rolemodel: ahead of a bare generator' do + class Rolemodel::RegistryProbeGenerator < ::Rails::Generators::Base; end + class RegistryProbeGenerator < ::Rails::Generators::Base; end + + class Rolemodel::ProbeDeltaGenerator < ::Rails::Generators::Base + hook_for :registry_probe, type: :boolean + end + + resolved = Rolemodel::ProbeDeltaGenerator.prepare_for_invocation(:registry_probe, :registry_probe) + expect(resolved).to eq Rolemodel::RegistryProbeGenerator + ensure + remove_generators Rolemodel::RegistryProbeGenerator, ::RegistryProbeGenerator, + Rolemodel::ProbeDeltaGenerator + end + end + + describe 'hook execution order' do + it 'runs hook invocations at their declaration position, not before or after all actions' do + apply_generator_config { |g| g.rolemodel registry_probe: true } + + class Rolemodel::RegistryProbeGenerator < ::Rails::Generators::Base + def record_hook_ran + REGISTRY_RESOLUTION_ORDER << :hook + end + end + + class Rolemodel::ProbeEpsilonGenerator < ::Rails::Generators::Base + def first_step + REGISTRY_RESOLUTION_ORDER << :first + end + + hook_for :registry_probe, type: :boolean + + def last_step + REGISTRY_RESOLUTION_ORDER << :last + end + end + + quietly { Rolemodel::ProbeEpsilonGenerator.start([]) } + + expect(REGISTRY_RESOLUTION_ORDER).to eq %i[first hook last] + ensure + remove_generators Rolemodel::RegistryProbeGenerator, Rolemodel::ProbeEpsilonGenerator + end + end + + describe 'engine eager-require path' do + it 'defines no hook-declaring generators when all_generator is required' do + root = File.expand_path('../..', __dir__) + script = <<~RUBY + require 'bundler/setup' + require 'rails' + require 'rails/generators' + $LOAD_PATH.unshift File.expand_path('lib') + require 'rolemodel-rails' + require 'generators/rolemodel/all_generator' + + hooked = Rails::Generators.subclasses.select do |klass| + klass.name.to_s.start_with?('Rolemodel') && klass.hooks.any? + end + if hooked.any? + warn "hook-declaring generators on the eager-require path: \#{hooked.map(&:name).join(', ')}" + exit 1 + end + RUBY + + _out, err, status = Open3.capture3(RbConfig.ruby, '-e', script, chdir: root) + + # Classes loaded eagerly by the engine's generators block resolve their + # hook defaults BEFORE Rails::Generators.configure! runs (verified against + # railties 8.1.2 Rails::Engine#load_generators), so they would silently + # ignore the registry. Coupling declarations belong on lazily-loaded leaf + # generators only. + expect(status).to be_success, err + end + end +end diff --git a/spec/support/helpers/generator_config.rb b/spec/support/helpers/generator_config.rb new file mode 100644 index 00000000..1f82efec --- /dev/null +++ b/spec/support/helpers/generator_config.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails/configuration' + +# Helpers for specs that exercise the generator registry's read path: +# Rails::Generators.options populated from a consuming app's config.generators. +# +# Rails::Generators memoizes that state process-globally and configure! +# deep-merges into it, so any example that touches it must run under the +# :generator_config metadata, which snapshots and restores the state around +# the example. Without it, configured keys leak into later examples and +# registry-dependent specs become order-dependent. +module GeneratorConfig + GENERATOR_STATE = %i[ + @options @aliases @fallbacks @templates_path @hidden_namespaces @after_generate_callbacks + ].freeze + + # Applies config the same way a consuming app's `config.generators` block + # does: through Rails::Configuration::Generators and Rails::Generators.configure!. + def apply_generator_config + config = Rails::Configuration::Generators.new + yield config + Rails::Generators.configure!(config) + end + + # Removes generator classes defined inside an example: deregisters them from + # Rails::Generators.subclasses (the namespace lookup index) and drops the constant. + def remove_generators(*klasses) + klasses.each do |klass| + Rails::Generators.subclasses.delete(klass) + parts = klass.name.split('::') + const = parts.pop + mod = parts.empty? ? Object : Object.const_get(parts.join('::')) + mod.send(:remove_const, const) if mod.const_defined?(const, false) + end + end + + def quietly + original = $stdout + $stdout = StringIO.new + yield + ensure + $stdout = original + end +end + +RSpec.configure do |config| + config.include GeneratorConfig + + config.around(:each, :generator_config) do |example| + saved = GeneratorConfig::GENERATOR_STATE.to_h do |ivar| + [ivar, Rails::Generators.instance_variable_get(ivar).deep_dup] + end + example.run + ensure + saved.each { |ivar, value| Rails::Generators.instance_variable_set(ivar, value) } + end +end From 9d01c33a0c0bfaaeb97dc4763282656ab6ecbf8e Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 16:08:21 -0500 Subject: [PATCH 15/21] feat: add generator registry and recording seam in GeneratorBase Every rolemodel generator now records its run in a marker-delimited managed block in config/initializers/rolemodel_generators.rb, read back through Rails' own config.generators option resolution. Recording skips --pretend and exempted classes, removes the entry on rails destroy, and never overwrites an explicit false (the persistent opt-out). --- lib/rolemodel/generator_base.rb | 52 +++++ lib/rolemodel/registry.rb | 199 ++++++++++++++++++ .../generator_base_recording_spec.rb | 145 +++++++++++++ spec/rolemodel/registry_spec.rb | 188 +++++++++++++++++ 4 files changed, 584 insertions(+) create mode 100644 lib/rolemodel/registry.rb create mode 100644 spec/rolemodel/generator_base_recording_spec.rb create mode 100644 spec/rolemodel/registry_spec.rb diff --git a/lib/rolemodel/generator_base.rb b/lib/rolemodel/generator_base.rb index 9dc50178..68b189eb 100644 --- a/lib/rolemodel/generator_base.rb +++ b/lib/rolemodel/generator_base.rb @@ -3,12 +3,43 @@ require 'rails/generators' require 'rails/generators/bundle_helper' require_relative 'replace_content_helper' +require_relative 'registry' module Rolemodel + # Shared base class for all rolemodel_rails generators: common helpers plus + # the registry recording seam that runs after every successful invocation. class GeneratorBase < ::Rails::Generators::Base include ::Rails::Generators::BundleHelper, ReplaceContentHelper + # Exempts a generator from registry recording (composites, hook + # sub-generators, the seeding generator). Inherited by subclasses. + def self.skip_registry_entry! + @skip_registry_entry = true + end + + def self.skip_registry_entry? + if instance_variable_defined?(:@skip_registry_entry) + @skip_registry_entry + else + superclass.respond_to?(:skip_registry_entry?) && superclass.skip_registry_entry? + end + end + + # Thor's method_added turns public instance methods into commands, and + # invoke_all is NOT in THOR_RESERVED_WORDS — without no_commands this + # override would itself become a command and recurse. + no_commands do + # The registry recording seam: once a run completes without raising, + # record this generator in the consuming app's registry initializer + # (or remove the entry under `rails destroy`). An exception during the + # run propagates and records nothing. + def invoke_all + super.tap { update_registry_entry } + end + end + private + # based on https://github.com/rails/rails/blob/main/railties/lib/rails/generators/app_base.rb#L713 def run_bundle bundle_command("install --quiet", "BUNDLE_IGNORE_MESSAGES" => "1") @@ -94,5 +125,26 @@ def wire_sentry_into_webpack def sentry_app_name Rails.application.class.module_parent_name.underscore end + + def update_registry_entry + return if self.class.skip_registry_entry? + + if behavior == :revoke + Registry.remove(Registry.key_for(self.class), destination_root: destination_root) + elsif behavior == :invoke && !options[:pretend] + record_registry_entry + end + end + + def record_registry_entry + key = Registry.key_for(self.class) + + case Registry.record(key, destination_root: destination_root) + when :recorded + say "Recorded #{key} in #{Registry::INITIALIZER_PATH}", :green + when :skipped_opt_out + say "Not recording #{key} — explicitly set to false in #{Registry::INITIALIZER_PATH}", :yellow + end + end end end diff --git a/lib/rolemodel/registry.rb b/lib/rolemodel/registry.rb new file mode 100644 index 00000000..1ed53ec0 --- /dev/null +++ b/lib/rolemodel/registry.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'date' +require 'fileutils' +require_relative 'version' + +module Rolemodel + # Persistent record of which rolemodel_rails generators have been applied to + # a consuming app. Entries live in a marker-delimited managed block inside + # config/initializers/rolemodel_generators.rb and are read back through + # Rails' own config.generators option resolution (Rails::Generators.options). + # + # Deliberately a plain module with no generator requires: it is loaded from + # the engine path (via GeneratorBase) and must stay safe to load there. + module Registry + # Raised when the initializer exists but its managed-block markers are + # missing — the writer never guesses where entries belong. + class MissingMarkersError < StandardError; end + + INITIALIZER_PATH = 'config/initializers/rolemodel_generators.rb' + BEGIN_MARKER = '# rolemodel_rails:begin' + END_MARKER = '# rolemodel_rails:end' + + ENTRY_PATTERN = /\A\s*g\.rolemodel (?\w+):\s*(?true|false)\b/ + + FILE_TEMPLATE = <<~RUBY.freeze + # frozen_string_literal: true + + # Records which rolemodel_rails generators have been applied to this app. + # Generators read this through Rails' own config.generators mechanism. + # Set an entry to `false` to permanently opt out of a coupling or + # prevent re-recording; delete the file and run rails g rolemodel:registry + # to rebuild it. Only edit between the markers if you know what you're doing. + Rails.application.config.generators do |g| + #{BEGIN_MARKER} + #{END_MARKER} + end + RUBY + + class << self + # Registry key for a generator class: its generator namespace with the + # rolemodel: prefix stripped and colons underscored. + # Rolemodel::WebpackGenerator -> :webpack + # Rolemodel::Optics::BaseGenerator -> :optics_base + def key_for(generator_class) + generator_class.namespace.delete_prefix('rolemodel:').tr(':', '_').to_sym + end + + # Whether a key is recorded, read through Rails::Generators.options — + # the hash the framework populates from the consuming app's + # config.generators. Guarded: returns false (never raises) in unbooted + # or no-config contexts, and for an explicit false opt-out entry. + def recorded?(key) + namespace = configured_namespace + return false unless namespace + + namespace[key.to_sym] == true + end + + # Upserts the entry line for +key+ inside the managed block of the + # initializer under +destination_root+, creating the whole file when + # absent. Returns :recorded, or :skipped_opt_out when the existing entry + # is an explicit false (the user's persistent opt-out — never overwritten). + def record(key, destination_root:, comment: default_comment) + path = initializer_path(destination_root) + write_template(path) unless File.exist?(path) + + block = ManagedBlock.new(path) + return :skipped_opt_out if block.opt_out?(key) + + block.upsert(key, comment) + :recorded + end + + # Deletes the entry line for +key+ (used by rails destroy). Returns + # :removed, :skipped_opt_out for an explicit false entry, or + # :not_recorded when there is nothing to remove. + def remove(key, destination_root:) + path = initializer_path(destination_root) + return :not_recorded unless File.exist?(path) + + block = ManagedBlock.new(path) + return :not_recorded unless block.entry?(key) + return :skipped_opt_out if block.opt_out?(key) + + block.delete(key) + :removed + end + + private + + def configured_namespace + return nil unless defined?(::Rails::Generators) + return nil unless ::Rails::Generators.respond_to?(:options) + + options = ::Rails::Generators.options + options[:rolemodel] if options.is_a?(Hash) + end + + def default_comment + "rolemodel_rails #{Rolemodel::VERSION}, #{Date.today.iso8601}" + end + + def initializer_path(destination_root) + File.expand_path(INITIALIZER_PATH, destination_root) + end + + def write_template(path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, FILE_TEMPLATE) + end + end + + # Line-level editor for the marker-delimited block of one initializer + # file. All mutation is anchored to entry lines between the markers; + # everything outside them is preserved byte for byte. + class ManagedBlock + def initialize(path) + @path = path + @lines = File.read(path).lines + @begin_index = marker_index(BEGIN_MARKER) + @end_index = marker_index(END_MARKER) + validate_markers! + end + + def entry?(key) + !entry_index(key).nil? + end + + def opt_out?(key) + index = entry_index(key) + index ? ENTRY_PATTERN.match(@lines[index])[:value] == 'false' : false + end + + def upsert(key, comment) + entry = entry_line(key, comment) + index = entry_index(key) + + if index + @lines[index] = entry + else + @lines.insert(insertion_index(key), entry) + end + + save + end + + def delete(key) + @lines.delete_at(entry_index(key)) + save + end + + private + + def marker_index(marker) + @lines.index { |line| line.strip == marker } + end + + def validate_markers! + return if @begin_index && @end_index && @begin_index < @end_index + + raise MissingMarkersError, <<~MESSAGE + #{@path} exists but its managed-block markers are missing. + Re-add `#{BEGIN_MARKER}` and `#{END_MARKER}` lines inside the + `Rails.application.config.generators` block (entries go between them), + or delete the file and run `bin/rails generate rolemodel:registry` to rebuild it. + MESSAGE + end + + def entry_range + (@begin_index + 1)...@end_index + end + + def entry_index(key) + entry_range.find do |index| + ENTRY_PATTERN.match(@lines[index])&.[](:key) == key.to_s + end + end + + # New entries slot in alphabetically among existing entry lines, or at + # the end of the block when no later key exists. + def insertion_index(key) + entry_range.find do |index| + match = ENTRY_PATTERN.match(@lines[index]) + match && match[:key] > key.to_s + end || @end_index + end + + def entry_line(key, comment) + indent = @lines[@begin_index][/\A[ \t]*/] + "#{indent}g.rolemodel #{key}: true # #{comment}\n" + end + + def save + File.write(@path, @lines.join) + end + end + end +end diff --git a/spec/rolemodel/generator_base_recording_spec.rb b/spec/rolemodel/generator_base_recording_spec.rb new file mode 100644 index 00000000..40cb07cb --- /dev/null +++ b/spec/rolemodel/generator_base_recording_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe Rolemodel::GeneratorBase, 'registry recording' do + around do |example| + Dir.mktmpdir do |dir| + @destination_root = dir + example.run + end + end + + attr_reader :destination_root + + def initializer_path + File.expand_path(Rolemodel::Registry::INITIALIZER_PATH, destination_root) + end + + def run_generator_class(klass, *args, behavior: :invoke) + output = StringIO.new + original = $stdout + $stdout = output + klass.start(args, destination_root: destination_root, behavior: behavior) + output.string + ensure + $stdout = original + end + + it 'does not register the invoke_all override as a Thor command' do + # An unwrapped public invoke_all would become a command (it is not in + # THOR_RESERVED_WORDS) and recurse on every generator run. + expect(described_class.all_commands.keys).not_to include('invoke_all') + end + + it 'records the derived key after a successful run and says so' do + class Rolemodel::RecordingProbeGenerator < Rolemodel::GeneratorBase + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + output = run_generator_class(Rolemodel::RecordingProbeGenerator) + + expect(File.read(initializer_path)) + .to include("g.rolemodel recording_probe: true # rolemodel_rails #{Rolemodel::VERSION}") + expect(output).to include('Recorded recording_probe') + ensure + remove_generators Rolemodel::RecordingProbeGenerator + end + + it 'records nothing under --pretend' do + class Rolemodel::RecordingProbeGenerator < Rolemodel::GeneratorBase + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + run_generator_class(Rolemodel::RecordingProbeGenerator, '--pretend') + + expect(File).not_to exist(initializer_path) + ensure + remove_generators Rolemodel::RecordingProbeGenerator + end + + it 'removes the entry under rails destroy (behavior :revoke)' do + Rolemodel::Registry.record(:recording_probe, destination_root: destination_root) + Rolemodel::Registry.record(:webpack, destination_root: destination_root) + + class Rolemodel::RecordingProbeGenerator < Rolemodel::GeneratorBase + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + run_generator_class(Rolemodel::RecordingProbeGenerator, behavior: :revoke) + + expect(File.read(initializer_path)).not_to include('recording_probe') + expect(File.read(initializer_path)).to include('g.rolemodel webpack: true') + ensure + remove_generators Rolemodel::RecordingProbeGenerator + end + + it 'records nothing when an action raises mid-run' do + class Rolemodel::ExplodingProbeGenerator < Rolemodel::GeneratorBase + def explode + raise 'boom' + end + end + + expect { run_generator_class(Rolemodel::ExplodingProbeGenerator) }.to raise_error('boom') + expect(File).not_to exist(initializer_path) + ensure + remove_generators Rolemodel::ExplodingProbeGenerator + end + + it 'records nothing for a generator marked skip_registry_entry!' do + class Rolemodel::ExemptProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + run_generator_class(Rolemodel::ExemptProbeGenerator) + + expect(File).not_to exist(initializer_path) + expect(File).to exist(File.expand_path('probe.txt', destination_root)) + ensure + remove_generators Rolemodel::ExemptProbeGenerator + end + + it 'inherits skip_registry_entry? and defaults it to false' do + class Rolemodel::ExemptProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + end + + class Rolemodel::ExemptChildProbeGenerator < Rolemodel::ExemptProbeGenerator; end + + expect(Rolemodel::GeneratorBase.skip_registry_entry?).to be false + expect(Rolemodel::ExemptChildProbeGenerator.skip_registry_entry?).to be true + ensure + remove_generators Rolemodel::ExemptChildProbeGenerator, Rolemodel::ExemptProbeGenerator + end + + it 'leaves an explicit false entry untouched and says why recording was skipped' do + Rolemodel::Registry.record(:recording_probe, destination_root: destination_root) + opted_out = File.read(initializer_path).sub('recording_probe: true', 'recording_probe: false') + File.write(initializer_path, opted_out) + + class Rolemodel::RecordingProbeGenerator < Rolemodel::GeneratorBase + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + output = run_generator_class(Rolemodel::RecordingProbeGenerator) + + expect(File.read(initializer_path)).to eq opted_out + expect(output).to include('Not recording recording_probe') + ensure + remove_generators Rolemodel::RecordingProbeGenerator + end +end diff --git a/spec/rolemodel/registry_spec.rb b/spec/rolemodel/registry_spec.rb new file mode 100644 index 00000000..57f771dd --- /dev/null +++ b/spec/rolemodel/registry_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe Rolemodel::Registry do + around do |example| + Dir.mktmpdir do |dir| + @destination_root = dir + example.run + end + end + + attr_reader :destination_root + + def initializer_path + File.expand_path(Rolemodel::Registry::INITIALIZER_PATH, destination_root) + end + + def initializer_content + File.read(initializer_path) + end + + def recorded_keys + initializer_content.scan(/g\.rolemodel (\w+):/).flatten + end + + describe '.key_for' do + it 'derives the key from the generator namespace with the rolemodel: prefix stripped' do + expect(described_class.key_for(Rolemodel::WebpackGenerator)).to eq :webpack + end + + it 'underscores nested namespaces' do + expect(described_class.key_for(Rolemodel::Optics::BaseGenerator)).to eq :optics_base + end + + it 'derives distinct keys for distinct AllGenerator classes' do + expect(described_class.key_for(Rolemodel::AllGenerator)).to eq :all + expect(described_class.key_for(Rolemodel::Optics::AllGenerator)).to eq :optics_all + end + end + + describe '.recorded?', :generator_config do + it 'returns false (does not raise) when no rolemodel config exists' do + expect(Rails::Generators.options[:rolemodel]).to be_nil + expect(described_class.recorded?(:webpack)).to be false + end + + it 'returns true for a key configured true' do + apply_generator_config { |g| g.rolemodel webpack: true } + + expect(described_class.recorded?(:webpack)).to be true + end + + it 'returns false for a key explicitly configured false' do + apply_generator_config { |g| g.rolemodel webpack: false } + + expect(described_class.recorded?(:webpack)).to be false + end + + it 'returns false for a key absent from the rolemodel namespace' do + apply_generator_config { |g| g.rolemodel webpack: true } + + expect(described_class.recorded?(:sentry)).to be false + end + end + + describe '.record' do + it 'creates the initializer with markers and one commented entry on first run' do + result = described_class.record(:webpack, destination_root: destination_root) + + expect(result).to eq :recorded + expect(initializer_content).to include(Rolemodel::Registry::BEGIN_MARKER) + expect(initializer_content).to include(Rolemodel::Registry::END_MARKER) + expect(initializer_content) + .to include("g.rolemodel webpack: true # rolemodel_rails #{Rolemodel::VERSION}, #{Date.today.iso8601}") + end + + it 'creates a file that is valid Ruby' do + described_class.record(:webpack, destination_root: destination_root) + + expect { RubyVM::InstructionSequence.compile(initializer_content) }.not_to raise_error + end + + it 'round-trips entries into config.generators, deep-merging into one namespace' do + described_class.record(:webpack, destination_root: destination_root) + described_class.record(:sentry, destination_root: destination_root) + + config = Rails::Configuration::Generators.new + app_config = double('app config') + allow(app_config).to receive(:generators) { |&block| block.call(config) } + allow(Rails).to receive(:application).and_return(double('application', config: app_config)) + + load initializer_path + + expect(config.options[:rolemodel]).to include(webpack: true, sentry: true) + end + + it 'appends new entries alphabetically without disturbing existing ones' do + described_class.record(:webpack, destination_root: destination_root, comment: 'first') + described_class.record(:sentry, destination_root: destination_root) + described_class.record(:optics_base, destination_root: destination_root) + + expect(recorded_keys).to eq %w[optics_base sentry webpack] + expect(initializer_content).to include('g.rolemodel webpack: true # first') + end + + it 're-recording refreshes the comment without duplicating the entry' do + described_class.record(:webpack, destination_root: destination_root, comment: 'rolemodel_rails 1.0.0, 2020-01-01') + result = described_class.record(:webpack, destination_root: destination_root, + comment: 'rolemodel_rails 2.0.0, 2021-02-02') + + expect(result).to eq :recorded + expect(initializer_content.scan(/g\.rolemodel webpack:/).length).to eq 1 + expect(initializer_content).to include('rolemodel_rails 2.0.0, 2021-02-02') + expect(initializer_content).not_to include('1.0.0') + end + + it 'never overwrites an explicit false entry' do + described_class.record(:webpack, destination_root: destination_root) + File.write(initializer_path, initializer_content.sub('webpack: true', 'webpack: false')) + opted_out = initializer_content + + result = described_class.record(:webpack, destination_root: destination_root) + + expect(result).to eq :skipped_opt_out + expect(initializer_content).to eq opted_out + end + + it 'raises with recovery instructions when the file exists without markers' do + FileUtils.mkdir_p(File.dirname(initializer_path)) + user_content = "# frozen_string_literal: true\n\n# hand-rolled file\n" + File.write(initializer_path, user_content) + + expect { described_class.record(:webpack, destination_root: destination_root) } + .to raise_error(Rolemodel::Registry::MissingMarkersError, /rolemodel:registry/) + expect(initializer_content).to eq user_content + end + + it 'leaves content outside the markers untouched' do + described_class.record(:webpack, destination_root: destination_root) + File.write(initializer_path, "# user note above the block\n#{initializer_content}# user note below\n") + + described_class.record(:sentry, destination_root: destination_root) + + expect(initializer_content).to start_with("# user note above the block\n") + expect(initializer_content).to end_with("# user note below\n") + expect(recorded_keys).to eq %w[sentry webpack] + end + end + + describe '.remove' do + it 'removes the entry line and leaves the others' do + described_class.record(:webpack, destination_root: destination_root) + described_class.record(:sentry, destination_root: destination_root) + + result = described_class.remove(:webpack, destination_root: destination_root) + + expect(result).to eq :removed + expect(recorded_keys).to eq %w[sentry] + expect(initializer_content).to include(Rolemodel::Registry::BEGIN_MARKER) + end + + it 'skips an explicit false entry' do + described_class.record(:webpack, destination_root: destination_root) + File.write(initializer_path, initializer_content.sub('webpack: true', 'webpack: false')) + + result = described_class.remove(:webpack, destination_root: destination_root) + + expect(result).to eq :skipped_opt_out + expect(initializer_content).to include('g.rolemodel webpack: false') + end + + it 'is a no-op when the file does not exist' do + result = described_class.remove(:webpack, destination_root: destination_root) + + expect(result).to eq :not_recorded + expect(File).not_to exist(initializer_path) + end + + it 'is a no-op when the key is not recorded' do + described_class.record(:webpack, destination_root: destination_root) + + expect(described_class.remove(:sentry, destination_root: destination_root)).to eq :not_recorded + expect(recorded_keys).to eq %w[webpack] + end + end +end From 3f523959472391c2ee11c1121c35a49001ce4ad3 Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 16:16:48 -0500 Subject: [PATCH 16/21] feat: add coupling_hook and requires_generator declaration macros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coupling_hook wraps a boolean hook_for with a registry-computed default (explicit config keys still win) and a skip note at the hook's position. requires_generator aborts before any action when a prerequisite is not recorded — invoke-only, so rails destroy teardown is never blocked. --- lib/rolemodel/generator_base.rb | 70 ++++++++++ spec/rolemodel/coupling_hook_spec.rb | 160 ++++++++++++++++++++++ spec/rolemodel/requires_generator_spec.rb | 145 ++++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 spec/rolemodel/coupling_hook_spec.rb create mode 100644 spec/rolemodel/requires_generator_spec.rb diff --git a/lib/rolemodel/generator_base.rb b/lib/rolemodel/generator_base.rb index 68b189eb..df735c9b 100644 --- a/lib/rolemodel/generator_base.rb +++ b/lib/rolemodel/generator_base.rb @@ -25,6 +25,57 @@ def self.skip_registry_entry? end end + # Declares an optional coupling with another generator: a boolean hook_for + # sharing +hook_key+ with the other side, defaulting to whether +with+ is + # recorded in the app's registry. The framework provides the rest: the + # --/--no- switch pair, the rolemodel: + # sub-generator lookup, and config-key precedence (an explicit + # `g.rolemodel : ...` entry overrides the computed default). + # + # The default resolves at class-DEFINITION time. That is correct because + # generator classes load after Rails::Generators.configure! in real runs — + # which is also why a coupling-declaring generator must never be + # eagerly required from the engine's generators block. + # + # Declaration position matters: hook invocations run where they are + # declared, so coupling_hook belongs AFTER the action methods it depends + # on — the wiring sub-generator must find the files those actions create. + def self.coupling_hook(hook_key, with:) + hook_for hook_key, type: :boolean, default: Registry.recorded?(with) + + skip_note = "Skipping #{hook_key} — #{with} is not recorded in #{Registry::INITIALIZER_PATH}. " \ + "Re-run this generator after installing rolemodel:#{with}, " \ + "or pass --#{hook_key.to_s.tr('_', '-')}." + + # Public on purpose: Thor registers it as the command immediately after + # the hook invocation, so the skip note prints exactly where the hook + # would have run. When the hook fires, its own invoke status line is + # the user-facing output and this stays silent. + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{hook_key}_skip_note + say #{skip_note.inspect}, :yellow unless options[:#{hook_key}] + end + RUBY + end + + # Declares a hard prerequisite: the generator aborts (via + # ensure_required_generators below) before any of its actions run unless + # +key+ is recorded in the app's registry. Deliberately exposes no CLI + # switch — hard prerequisites are not bypassable per-invocation; the + # remedies are installing the missing generator or seeding the registry. + def self.requires_generator(key) + @required_generator_keys = required_generator_keys + [key.to_sym] + end + + # Accumulated prerequisite keys, inherited by subclasses. + def self.required_generator_keys + if instance_variable_defined?(:@required_generator_keys) + @required_generator_keys + else + superclass.respond_to?(:required_generator_keys) ? superclass.required_generator_keys : [] + end + end + # Thor's method_added turns public instance methods into commands, and # invoke_all is NOT in THOR_RESERVED_WORDS — without no_commands this # override would itself become a command and recurse. @@ -38,6 +89,25 @@ def invoke_all end end + # The requires_generator guard. Public on purpose: Thor turns it into a + # command, and because inherited commands run before subclass commands it + # executes before any subclass action for every generator. It shows up in + # every generator's command list, so the name must say what it does — + # and nothing else on GeneratorBase may be public. + def ensure_required_generators + return unless behavior == :invoke # rails destroy teardown is never blocked + + missing = self.class.required_generator_keys.reject { |key| Registry.recorded?(key) } + return if missing.empty? + + raise Thor::Error, <<~MESSAGE + #{self.class.namespace} requires #{missing.join(', ')} to be installed first, + but no entry is recorded in #{Registry::INITIALIZER_PATH}. + Install the missing generator (bin/rails generate rolemodel:#{missing.first}) and re-run, + or run bin/rails generate rolemodel:registry to seed the registry in an existing app. + MESSAGE + end + private # based on https://github.com/rails/rails/blob/main/railties/lib/rails/generators/app_base.rb#L713 diff --git a/spec/rolemodel/coupling_hook_spec.rb b/spec/rolemodel/coupling_hook_spec.rb new file mode 100644 index 00000000..be9519ca --- /dev/null +++ b/spec/rolemodel/coupling_hook_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Runtime trace of throwaway generator commands, so examples can assert +# whether the hook target actually ran. +COUPLING_HOOK_EVENTS = [] + +RSpec.describe 'Rolemodel::GeneratorBase.coupling_hook', :generator_config do + after { COUPLING_HOOK_EVENTS.clear } + + # Runs a throwaway generator and returns everything it printed. The hosts + # create no files and are marked skip_registry_entry!, so no destination + # root is needed. + def run_generator_class(klass, *args) + output = StringIO.new + original = $stdout + $stdout = output + klass.start(args) + output.string + ensure + $stdout = original + end + + it 'invokes the rolemodel: target after the actions when the other side is recorded' do + apply_generator_config { |g| g.rolemodel other_side_probe: true } + + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator) + + expect(COUPLING_HOOK_EVENTS).to eq %i[action target] + expect(output).not_to include('Skipping coupling_probe') + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end + + it 'skips the hook and prints the re-run note when the other side is not recorded' do + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator) + + expect(COUPLING_HOOK_EVENTS).to eq %i[action] + expect(output).to include( + 'Skipping coupling_probe — other_side_probe is not recorded in ' \ + 'config/initializers/rolemodel_generators.rb. ' \ + 'Re-run this generator after installing rolemodel:other_side_probe, ' \ + 'or pass --coupling-probe.' + ) + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end + + it 'honors an explicit config opt-out even when the other side is recorded' do + apply_generator_config { |g| g.rolemodel other_side_probe: true, coupling_probe: false } + + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator) + + expect(COUPLING_HOOK_EVENTS).to eq %i[action] + expect(output).to include('Skipping coupling_probe') + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end + + it 'fires the hook on an explicit -- switch with an empty registry (AE1)' do + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator, '--coupling-probe') + + expect(COUPLING_HOOK_EVENTS).to eq %i[action target] + expect(output).not_to include('Skipping coupling_probe') + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end + + it 'skips the hook on --no- even when the other side is recorded' do + apply_generator_config { |g| g.rolemodel other_side_probe: true } + + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator, '--no-coupling-probe') + + expect(COUPLING_HOOK_EVENTS).to eq %i[action] + expect(output).to include('Skipping coupling_probe') + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end +end diff --git a/spec/rolemodel/requires_generator_spec.rb b/spec/rolemodel/requires_generator_spec.rb new file mode 100644 index 00000000..b8cccf3c --- /dev/null +++ b/spec/rolemodel/requires_generator_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe 'Rolemodel::GeneratorBase.requires_generator', :generator_config do + around do |example| + Dir.mktmpdir do |dir| + @destination_root = dir + example.run + end + end + + attr_reader :destination_root + + # Thor::Base.start rescues Thor::Error (prints it and exits non-zero), so + # examples asserting on the raised error drive the generator through + # invoke_all — the same entry point start uses underneath. + def build_generator(klass, behavior: :invoke) + klass.new([], {}, destination_root: destination_root, behavior: behavior) + end + + def probe_file + File.expand_path('probe.txt', destination_root) + end + + it 'raises Thor::Error before any action runs when the key is not recorded' do + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + expect { build_generator(Rolemodel::GuardedProbeGenerator).invoke_all } + .to raise_error(Thor::Error, /prerequisite_probe/) + expect(File).not_to exist(probe_file) + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'names the missing generator and the seeder in the abort message' do + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + end + + expect { build_generator(Rolemodel::GuardedProbeGenerator).invoke_all } + .to raise_error(Thor::Error) do |error| + expect(error.message).to include('bin/rails generate rolemodel:prerequisite_probe') + expect(error.message).to include('bin/rails generate rolemodel:registry') + end + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'aborts a start-driven run with the message on stderr and no files created' do + # Thor::Base.start rescues Thor::Error and reports it through the shell; + # Rails::Generators::Base.exit_on_failure? is false (railties 8.1.2), so + # no SystemExit is raised here — bin/rails handles the exit status. + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + expect { Rolemodel::GuardedProbeGenerator.start([], destination_root: destination_root) } + .to output(/prerequisite_probe/).to_stderr + expect(File).not_to exist(probe_file) + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'proceeds when the key is recorded' do + apply_generator_config { |g| g.rolemodel prerequisite_probe: true } + + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + quietly { build_generator(Rolemodel::GuardedProbeGenerator).invoke_all } + + expect(File).to exist(probe_file) + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'no-ops for a generator with no declarations' do + class Rolemodel::UnguardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + quietly { build_generator(Rolemodel::UnguardedProbeGenerator).invoke_all } + + expect(File).to exist(probe_file) + ensure + remove_generators Rolemodel::UnguardedProbeGenerator + end + + it 'never blocks rails destroy: an unmet prerequisite proceeds under behavior :revoke' do + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + expect { quietly { build_generator(Rolemodel::GuardedProbeGenerator, behavior: :revoke).invoke_all } } + .not_to raise_error + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'accumulates keys down the inheritance chain without polluting ancestors' do + class Rolemodel::GuardedParentGenerator < Rolemodel::GeneratorBase + requires_generator :alpha_probe + end + + class Rolemodel::GuardedChildGenerator < Rolemodel::GuardedParentGenerator + requires_generator :beta_probe + end + + expect(Rolemodel::GuardedChildGenerator.required_generator_keys).to eq %i[alpha_probe beta_probe] + expect(Rolemodel::GuardedParentGenerator.required_generator_keys).to eq %i[alpha_probe] + expect(Rolemodel::GeneratorBase.required_generator_keys).to eq [] + ensure + remove_generators Rolemodel::GuardedChildGenerator, Rolemodel::GuardedParentGenerator + end +end From 9ddd8dcd8486c0286ccded0cbb632d16a30f01a1 Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 16:44:39 -0500 Subject: [PATCH 17/21] feat: convert sentry<->webpack coupling to a hook sub-generator --- .gitignore | 3 + .../rolemodel/sentry/sentry_generator.rb | 9 ++- .../rolemodel/sentry_webpack/README.md | 26 ++++++++ lib/generators/rolemodel/sentry_webpack/USAGE | 10 +++ .../sentry_webpack_generator.rb | 62 +++++++++++++++++++ .../rolemodel/webpack/webpack_generator.rb | 14 ++--- lib/rolemodel/generator_base.rb | 44 ------------- .../rolemodel/sentry_generator_spec.rb | 7 ++- .../sentry_webpack_generator_spec.rb | 41 ++++++++++++ .../rolemodel/webpack_generator_spec.rb | 4 +- 10 files changed, 157 insertions(+), 63 deletions(-) create mode 100644 lib/generators/rolemodel/sentry_webpack/README.md create mode 100644 lib/generators/rolemodel/sentry_webpack/USAGE create mode 100644 lib/generators/rolemodel/sentry_webpack/sentry_webpack_generator.rb create mode 100644 spec/generators/rolemodel/sentry_webpack_generator_spec.rb diff --git a/.gitignore b/.gitignore index 8d57097e..7fdfa5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ log .history .DS_Store + +# Leaked app config from running generators at the repo root during dev/specs +/config/ diff --git a/lib/generators/rolemodel/sentry/sentry_generator.rb b/lib/generators/rolemodel/sentry/sentry_generator.rb index dbdf5e2e..e1a82158 100644 --- a/lib/generators/rolemodel/sentry/sentry_generator.rb +++ b/lib/generators/rolemodel/sentry/sentry_generator.rb @@ -68,11 +68,10 @@ def add_js_initializer JS end - # For standalone `rolemodel:sentry` runs; when run via core_setup the webpack - # generator has already wired this (and this call is a no-op). - def configure_webpack - wire_sentry_into_webpack - end + # Optional coupling: when rolemodel:webpack is already installed, wire the + # Sentry plugin into its webpack.config.js. Declared after the action + # methods so the wiring runs once the config file it edits is present. + coupling_hook :sentry_webpack, with: :webpack def finishing_notes say <<~NOTES diff --git a/lib/generators/rolemodel/sentry_webpack/README.md b/lib/generators/rolemodel/sentry_webpack/README.md new file mode 100644 index 00000000..92c9bf85 --- /dev/null +++ b/lib/generators/rolemodel/sentry_webpack/README.md @@ -0,0 +1,26 @@ +# Sentry Webpack Generator + +Wiring-only hook sub-generator that injects the `sentryWebpackPlugin` into +`webpack.config.js` so production builds upload source maps to Sentry. + +## Why it exists + +The sentry and webpack generators are coupled: whichever one is installed +second should wire Sentry into the webpack config. Rather than duplicating that +wiring on both generators, both declare a `coupling_hook :sentry_webpack`, and +this sub-generator is the single shared target that does the wiring. + +You normally never run this directly. It fires automatically when: + +* you run `rolemodel:sentry` in an app where `rolemodel:webpack` is recorded, or +* you run `rolemodel:webpack` in an app where `rolemodel:sentry` is recorded. + +Pass `--sentry-webpack` to force it, or `--no-sentry-webpack` to suppress it. + +## Behavior + +* Idempotent: a no-op when there is no `webpack.config.js` or the plugin is + already wired. +* Not recorded in the registry — it is not an installable generator on its own. +* After running, update the `project` and `applicationKey` in + `webpack.config.js` to match your Sentry project. diff --git a/lib/generators/rolemodel/sentry_webpack/USAGE b/lib/generators/rolemodel/sentry_webpack/USAGE new file mode 100644 index 00000000..de211cf3 --- /dev/null +++ b/lib/generators/rolemodel/sentry_webpack/USAGE @@ -0,0 +1,10 @@ +Description: + Wires the Sentry webpack plugin into webpack.config.js. This is a hook + sub-generator invoked automatically by the sentry<->webpack coupling; you + normally do not run it directly. + +Example: + rails generate rolemodel:sentry_webpack + + This will inject the sentryWebpackPlugin into webpack.config.js (a no-op if + webpack.config.js is missing or the plugin is already wired). diff --git a/lib/generators/rolemodel/sentry_webpack/sentry_webpack_generator.rb b/lib/generators/rolemodel/sentry_webpack/sentry_webpack_generator.rb new file mode 100644 index 00000000..0c04190f --- /dev/null +++ b/lib/generators/rolemodel/sentry_webpack/sentry_webpack_generator.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Rolemodel + # Wiring-only hook sub-generator: injects the Sentry webpack plugin into + # webpack.config.js. It is the shared target of the sentry<->webpack + # coupling_hook on both the sentry and webpack generators, so the wiring + # lives in exactly one place regardless of which side is installed second. + # + # Exempt from registry recording (it is not an installable generator in its + # own right) and idempotent: a no-op when there is no webpack.config.js or + # the plugin is already wired. + class SentryWebpackGenerator < GeneratorBase + skip_registry_entry! + + def wire_sentry_into_webpack + config = File.expand_path('webpack.config.js', destination_root) + return unless File.exist?(config) + return if File.read(config).include?('@sentry/webpack-plugin') + + say 'Wiring Sentry into webpack.config.js', :green + + inject_into_file 'webpack.config.js', + "import { sentryWebpackPlugin } from '@sentry/webpack-plugin'\n", + after: "import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'\n" + + inject_into_file 'webpack.config.js', after: "'process.env.RAILS_ENV': JSON.stringify(process.env.RAILS_ENV),\n" do + <<-JS + 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), + 'process.env.SENTRY_ENVIRONMENT': JSON.stringify(process.env.SENTRY_ENVIRONMENT), + 'process.env.SENTRY_AUTH_TOKEN': JSON.stringify(process.env.SENTRY_AUTH_TOKEN), + JS + end + + gsub_file 'webpack.config.js', " })\n ].filter(Boolean)", <<-JS.chomp + }), + + // Upload source maps to Sentry in production for easier debugging + mode === 'production' && + !process.env.CI && + sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: 'rolemodel-software', + project: '#{project_name}', + telemetry: false, + applicationKey: 'app-frontend' + }) + ].filter(Boolean) + JS + end + + private + + # Sentry project slug for the webpack plugin. Guarded so the generator is + # safe to load and run in unbooted contexts (falls back to a placeholder + # the finishing notes tell the user to replace). + def project_name + return 'app-frontend' unless defined?(::Rails) && ::Rails.application + + ::Rails.application.class.module_parent_name.underscore + end + end +end diff --git a/lib/generators/rolemodel/webpack/webpack_generator.rb b/lib/generators/rolemodel/webpack/webpack_generator.rb index 9117f567..0a0d13e9 100644 --- a/lib/generators/rolemodel/webpack/webpack_generator.rb +++ b/lib/generators/rolemodel/webpack/webpack_generator.rb @@ -2,9 +2,6 @@ module Rolemodel class WebpackGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) - class_option :sentry, type: :boolean, default: false, - desc: 'Wire the Sentry webpack plugin into webpack.config.js' - DEV_DEPS = %w[ esbuild esbuild-loader @@ -71,12 +68,9 @@ def add_webpack_config copy_file 'webpack.config.js', force: true end - # Wiring the Sentry webpack plugin lives here (rather than in the Sentry - # generator) so the whole webpack.config.js is owned by one generator. - def configure_sentry - return unless options.sentry? - - wire_sentry_into_webpack - end + # Optional coupling: when rolemodel:sentry is already installed, wire its + # plugin into the webpack.config.js this generator just created. Declared + # after the action methods so the config file exists before wiring runs. + coupling_hook :sentry_webpack, with: :sentry end end diff --git a/lib/rolemodel/generator_base.rb b/lib/rolemodel/generator_base.rb index df735c9b..31b27f70 100644 --- a/lib/rolemodel/generator_base.rb +++ b/lib/rolemodel/generator_base.rb @@ -152,50 +152,6 @@ def ignore_yarn_install_state append_to_file '.gitignore', "\n/.yarn/install-state.gz\n" end - # Single source of truth for wiring the Sentry webpack plugin into - # webpack.config.js. Shared by the webpack generator (which owns the config - # file) and the sentry generator (for standalone runs). Idempotent and a - # no-op if there's no webpack.config.js or it's already wired. - def wire_sentry_into_webpack - config = File.expand_path('webpack.config.js', destination_root) - return unless File.exist?(config) - return if File.read(config).include?('@sentry/webpack-plugin') - - say 'Wiring Sentry into webpack.config.js', :green - - inject_into_file 'webpack.config.js', - "import { sentryWebpackPlugin } from '@sentry/webpack-plugin'\n", - after: "import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'\n" - - inject_into_file 'webpack.config.js', after: "'process.env.RAILS_ENV': JSON.stringify(process.env.RAILS_ENV),\n" do - <<-JS - 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), - 'process.env.SENTRY_ENVIRONMENT': JSON.stringify(process.env.SENTRY_ENVIRONMENT), - 'process.env.SENTRY_AUTH_TOKEN': JSON.stringify(process.env.SENTRY_AUTH_TOKEN), - JS - end - - gsub_file 'webpack.config.js', " })\n ].filter(Boolean)", <<-JS.chomp - }), - - // Upload source maps to Sentry in production for easier debugging - mode === 'production' && - !process.env.CI && - sentryWebpackPlugin({ - authToken: process.env.SENTRY_AUTH_TOKEN, - org: 'rolemodel-software', - project: '#{Rails.application.name}', - telemetry: false, - applicationKey: 'app-frontend' - }) - ].filter(Boolean) - JS - end - - def sentry_app_name - Rails.application.class.module_parent_name.underscore - end - def update_registry_entry return if self.class.skip_registry_entry? diff --git a/spec/generators/rolemodel/sentry_generator_spec.rb b/spec/generators/rolemodel/sentry_generator_spec.rb index 7b02b07e..ee9e22b9 100644 --- a/spec/generators/rolemodel/sentry_generator_spec.rb +++ b/spec/generators/rolemodel/sentry_generator_spec.rb @@ -1,9 +1,12 @@ RSpec.describe Rolemodel::SentryGenerator, type: :generator do before do # The webpack generator lays down the webpack.config.js and application.js - # that this generator injects into. + # that this generator injects into. In the eager-load spec context webpack + # is not "recorded", so the sentry<->webpack coupling is forced explicitly + # via --sentry-webpack (the coupling's own resolution is covered in + # spec/rolemodel/coupling_hook_spec.rb). run_generator_against_test_app generator: ::Rolemodel::WebpackGenerator - run_generator_against_test_app + run_generator_against_test_app(['--sentry-webpack']) end it 'sets up the Ruby side' do diff --git a/spec/generators/rolemodel/sentry_webpack_generator_spec.rb b/spec/generators/rolemodel/sentry_webpack_generator_spec.rb new file mode 100644 index 00000000..7708c517 --- /dev/null +++ b/spec/generators/rolemodel/sentry_webpack_generator_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.describe Rolemodel::SentryWebpackGenerator, type: :generator do + it 'wires the Sentry plugin into the webpack.config.js' do + # The webpack generator lays down the modern config with the anchors this + # hook injects into. + run_generator_against_test_app generator: ::Rolemodel::WebpackGenerator + run_generator_against_test_app + + assert_file 'webpack.config.js' do |content| + expect(content).to include("import { sentryWebpackPlugin } from '@sentry/webpack-plugin'") + expect(content).to include("'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN),") + expect(content).to include('sentryWebpackPlugin({') + expect(content).to include('].filter(Boolean)') + end + end + + it 'is a no-op when there is no webpack.config.js' do + File.delete(File.expand_path('webpack.config.js', destination_root)) + + run_generator_against_test_app + + assert_no_file 'webpack.config.js' + end + + it 'is idempotent when the plugin is already wired' do + run_generator_against_test_app generator: ::Rolemodel::WebpackGenerator + run_generator_against_test_app + first_pass = File.read(File.expand_path('webpack.config.js', destination_root)) + + run_generator_against_test_app + + expect(File.read(File.expand_path('webpack.config.js', destination_root))).to eq first_pass + end + + it 'records no registry entry (it is a wiring-only hook)' do + run_generator_against_test_app + + assert_no_file 'config/initializers/rolemodel_generators.rb' + end +end diff --git a/spec/generators/rolemodel/webpack_generator_spec.rb b/spec/generators/rolemodel/webpack_generator_spec.rb index 7e19ad1e..9ba7bf37 100644 --- a/spec/generators/rolemodel/webpack_generator_spec.rb +++ b/spec/generators/rolemodel/webpack_generator_spec.rb @@ -38,8 +38,8 @@ end end - context 'with the --sentry option' do - before { run_generator_against_test_app(['--sentry']) } + context 'with the --sentry-webpack option' do + before { run_generator_against_test_app(['--sentry-webpack']) } it 'wires the Sentry plugin into webpack.config.js' do assert_file 'webpack.config.js' do |content| From b96b2769595b884ad431816f183c6899b1dab0f8 Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 16:50:29 -0500 Subject: [PATCH 18/21] feat: drive tailored_select's SimpleForm input off the registry --- .../simple_form/simple_form_generator.rb | 6 ++++-- .../tailored_select_generator.rb | 21 ++++++++++++------- .../rolemodel/simple_form_generator_spec.rb | 4 ++-- .../tailored_select_generator_spec.rb | 7 +++---- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/lib/generators/rolemodel/simple_form/simple_form_generator.rb b/lib/generators/rolemodel/simple_form/simple_form_generator.rb index a27b637b..5f7af266 100644 --- a/lib/generators/rolemodel/simple_form/simple_form_generator.rb +++ b/lib/generators/rolemodel/simple_form/simple_form_generator.rb @@ -24,8 +24,10 @@ def install_tailored_select return unless options.tailored_select? # The tailored_select generator owns the input template and installs it - # here because simple_form is now present. - generate 'rolemodel:tailored_select' + # here because simple_form is now present. simple_form is not recorded in + # the registry until this run completes, so pass the switch explicitly + # rather than relying on the child's registry-based default. + generate 'rolemodel:tailored_select', '--simple-form-input' end end end diff --git a/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb b/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb index a061b5b9..51d978dd 100644 --- a/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb +++ b/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb @@ -4,6 +4,14 @@ module Rolemodel class TailoredSelectGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # The simple_form input is only useful once simple_form is installed. Its + # default reflects whether simple_form is recorded in the app's registry; + # --simple-form-input / --no-simple-form-input override per invocation, and + # simple_form's own delegation passes it explicitly (that run records + # simple_form only at completion, after this child has already run). + class_option :simple_form_input, type: :boolean, default: Registry.recorded?(:simple_form), + desc: 'Install the tailored_select SimpleForm input' + def add_tailored_select_package say 'Installing Tailored Select package', :green @@ -12,17 +20,16 @@ def add_tailored_select_package end def add_simple_form_input - return unless simple_form? + unless options.simple_form_input? + say 'Skipping the Tailored Select SimpleForm input — simple_form is not recorded in ' \ + "#{Registry::INITIALIZER_PATH}. Re-run after rolemodel:simple_form, or pass --simple-form-input.", + :yellow + return + end say 'Installing the Tailored Select SimpleForm input', :green copy_file 'app/inputs/tailored_select_input.rb' end - - private - - def simple_form? - File.exist?(File.expand_path('config/initializers/simple_form.rb', destination_root)) - end end end diff --git a/spec/generators/rolemodel/simple_form_generator_spec.rb b/spec/generators/rolemodel/simple_form_generator_spec.rb index b549c60a..d81fff47 100644 --- a/spec/generators/rolemodel/simple_form_generator_spec.rb +++ b/spec/generators/rolemodel/simple_form_generator_spec.rb @@ -32,14 +32,14 @@ def invoke_simple_form(args = []) allow(generator).to receive(:generate) { |*invocation| invocations << invocation } # Stub the parts that touch the filesystem/network; we only care about # whether the tailored_select generator is delegated to. - %i[add_gem add_files].each { |step| allow(generator).to receive(step) } + %i[add_gem add_files update_registry_entry].each { |step| allow(generator).to receive(step) } generator.invoke_all end it 'delegates to the tailored_select generator when passed' do invoke_simple_form(['--tailored_select']) - expect(invocations).to include(['rolemodel:tailored_select']) + expect(invocations).to include(['rolemodel:tailored_select', '--simple-form-input']) end it 'does not delegate to the tailored_select generator by default' do diff --git a/spec/generators/rolemodel/tailored_select_generator_spec.rb b/spec/generators/rolemodel/tailored_select_generator_spec.rb index 6644b888..592ac8e2 100644 --- a/spec/generators/rolemodel/tailored_select_generator_spec.rb +++ b/spec/generators/rolemodel/tailored_select_generator_spec.rb @@ -7,15 +7,14 @@ end end - it 'does not install the SimpleForm input when SimpleForm is absent' do + it 'does not install the SimpleForm input by default (SimpleForm not recorded)' do run_generator_against_test_app assert_no_file 'app/inputs/tailored_select_input.rb' end - it 'installs the SimpleForm input when SimpleForm is present' do - run_generator_against_test_app generator: ::Rolemodel::SimpleFormGenerator - run_generator_against_test_app + it 'installs the SimpleForm input when passed --simple-form-input' do + run_generator_against_test_app(['--simple-form-input']) assert_file 'app/inputs/tailored_select_input.rb' end From a8b693e77173593f2183dc8d087c448f4029ca5f Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 17:22:02 -0500 Subject: [PATCH 19/21] feat: remove coupling flags and semaphore from composite generators - Strip --sentry flag from webpack invocation in core_setup - Remove stale rolemodel:semaphore invocation from all_generator - Add skip_registry_entry! to all 6 composite generators - Pin Rails abort_on_failure behavior with spec - Verify composite exemption from registry recording --- lib/generators/rolemodel/all_generator.rb | 5 ++- .../core_setup/core_setup_generator.rb | 6 ++- .../rolemodel/linters/all_generator.rb | 3 ++ .../rolemodel/optics/all_generator.rb | 3 ++ .../rolemodel/saas/all_generator.rb | 3 ++ .../rolemodel/testing/all_generator.rb | 3 ++ .../rolemodel/ui_components/all_generator.rb | 3 ++ .../rolemodel/core_setup_generator_spec.rb | 45 ++++++++++++++++--- 8 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lib/generators/rolemodel/all_generator.rb b/lib/generators/rolemodel/all_generator.rb index c9a84d60..83a3fd01 100644 --- a/lib/generators/rolemodel/all_generator.rb +++ b/lib/generators/rolemodel/all_generator.rb @@ -2,9 +2,12 @@ module Rolemodel class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: it orchestrates other generators and installs nothing + # itself, so it is not recorded in the registry. + skip_registry_entry! + def run_all_the_generators generate 'rolemodel:github' - generate 'rolemodel:semaphore' generate 'rolemodel:heroku' generate 'rolemodel:readme' generate 'rolemodel:webpack' diff --git a/lib/generators/rolemodel/core_setup/core_setup_generator.rb b/lib/generators/rolemodel/core_setup/core_setup_generator.rb index 5ab81f9a..bf44b1bb 100644 --- a/lib/generators/rolemodel/core_setup/core_setup_generator.rb +++ b/lib/generators/rolemodel/core_setup/core_setup_generator.rb @@ -1,10 +1,14 @@ module Rolemodel class CoreSetupGenerator < ::Rolemodel::GeneratorBase + # Composite generator: it orchestrates other generators and installs nothing + # itself, so it is not recorded in the registry. + skip_registry_entry! + def run_the_core_generators generate 'rolemodel:github' generate 'rolemodel:heroku' generate 'rolemodel:readme' - generate 'rolemodel:webpack --sentry' + generate 'rolemodel:webpack' generate 'rolemodel:sentry' generate 'rolemodel:slim' generate 'rolemodel:optics:all' diff --git a/lib/generators/rolemodel/linters/all_generator.rb b/lib/generators/rolemodel/linters/all_generator.rb index 432fe4a0..e78cc986 100644 --- a/lib/generators/rolemodel/linters/all_generator.rb +++ b/lib/generators/rolemodel/linters/all_generator.rb @@ -3,6 +3,9 @@ module Linters class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + def run_all_the_generators Dir.glob(Pathname(File.expand_path('.', __dir__)).join('*', '*generator.rb')).each do |generator| name = File.basename(generator, '_generator.rb') diff --git a/lib/generators/rolemodel/optics/all_generator.rb b/lib/generators/rolemodel/optics/all_generator.rb index 8b6937bf..db08a7df 100644 --- a/lib/generators/rolemodel/optics/all_generator.rb +++ b/lib/generators/rolemodel/optics/all_generator.rb @@ -3,6 +3,9 @@ module Optics class AllGenerator < Rolemodel::GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + def run_all_the_generators generate 'rolemodel:optics:base' generate 'rolemodel:optics:icons' diff --git a/lib/generators/rolemodel/saas/all_generator.rb b/lib/generators/rolemodel/saas/all_generator.rb index 2e12740f..42e2b7d1 100644 --- a/lib/generators/rolemodel/saas/all_generator.rb +++ b/lib/generators/rolemodel/saas/all_generator.rb @@ -3,6 +3,9 @@ module Saas class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + def run_all_the_generators # no guaranteed order to this list with Dir.glob Dir.glob(Pathname(File.expand_path('.', __dir__)).join('*', '*generator.rb')).each do |generator| diff --git a/lib/generators/rolemodel/testing/all_generator.rb b/lib/generators/rolemodel/testing/all_generator.rb index db1b1515..e6529af2 100644 --- a/lib/generators/rolemodel/testing/all_generator.rb +++ b/lib/generators/rolemodel/testing/all_generator.rb @@ -3,6 +3,9 @@ module Testing class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + class_option :js_runner, type: :boolean, default: false, desc: 'Include jasmine-playwright-runner for browser testing' def run_all_the_generators diff --git a/lib/generators/rolemodel/ui_components/all_generator.rb b/lib/generators/rolemodel/ui_components/all_generator.rb index 0f9fcf26..0b9bfc42 100644 --- a/lib/generators/rolemodel/ui_components/all_generator.rb +++ b/lib/generators/rolemodel/ui_components/all_generator.rb @@ -3,6 +3,9 @@ module UiComponents class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + def run_all_the_generators # no guaranteed order to this list with Dir.glob Dir.glob(Pathname(File.expand_path('.', __dir__)).join('*', '*generator.rb')).each do |generator| diff --git a/spec/generators/rolemodel/core_setup_generator_spec.rb b/spec/generators/rolemodel/core_setup_generator_spec.rb index 477814c1..807d5478 100644 --- a/spec/generators/rolemodel/core_setup_generator_spec.rb +++ b/spec/generators/rolemodel/core_setup_generator_spec.rb @@ -1,20 +1,27 @@ RSpec.describe Rolemodel::CoreSetupGenerator do let(:invocations) { [] } - def invoke_core_setup(args = []) - generator = described_class.new([], args) - allow(generator).to receive(:generate) { |*invocation| invocations << invocation } - generator.invoke_all + def build_generator(args = [], &stub) + described_class.new([], args).tap do |generator| + allow(generator).to receive(:generate) do |*invocation| + invocations << invocation + stub&.call(invocation) + end + end end - it 'runs the core generators in order' do + def invoke_core_setup(args = [], &stub) + build_generator(args, &stub).invoke_all + end + + it 'runs the core generators in order without the removed --sentry flag' do invoke_core_setup expect(invocations).to eq [ ['rolemodel:github'], ['rolemodel:heroku'], ['rolemodel:readme'], - ['rolemodel:webpack --sentry'], + ['rolemodel:webpack'], ['rolemodel:sentry'], ['rolemodel:slim'], ['rolemodel:optics:all'], @@ -26,4 +33,30 @@ def invoke_core_setup(args = []) ['rolemodel:lograge'] ] end + + it 'does not invoke the removed semaphore generator' do + invoke_core_setup + + expect(invocations).not_to include(['rolemodel:semaphore']) + end + + it 'aborts the whole run when a child generator fails' do + expect do + invoke_core_setup do |invocation| + raise SystemExit if invocation == ['rolemodel:webpack'] + end + end.to raise_error(SystemExit) + + # Children after the failing one never run. + expect(invocations).to eq [ + ['rolemodel:github'], + ['rolemodel:heroku'], + ['rolemodel:readme'], + ['rolemodel:webpack'] + ] + end + + it 'is exempt from registry recording (it is a composite)' do + expect(described_class.skip_registry_entry?).to be(true) + end end From 7799718d05c932d5580215af574479ef30297fba Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 17:38:28 -0500 Subject: [PATCH 20/21] feat: add registry seeding generator for existing apps --- lib/generators/rolemodel/registry/README.md | 32 ++++ lib/generators/rolemodel/registry/USAGE | 15 ++ .../rolemodel/registry/registry_generator.rb | 137 ++++++++++++++++++ .../rolemodel/registry_generator_spec.rb | 123 ++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 lib/generators/rolemodel/registry/README.md create mode 100644 lib/generators/rolemodel/registry/USAGE create mode 100644 lib/generators/rolemodel/registry/registry_generator.rb create mode 100644 spec/generators/rolemodel/registry_generator_spec.rb diff --git a/lib/generators/rolemodel/registry/README.md b/lib/generators/rolemodel/registry/README.md new file mode 100644 index 00000000..66ee8d52 --- /dev/null +++ b/lib/generators/rolemodel/registry/README.md @@ -0,0 +1,32 @@ +# Registry Generator + +One-time seeding tool for apps that were set up before the rolemodel_rails +generator registry existed. It probes for the characteristic output files of +each generator in the suite and writes detection-based entries into your app's +`config/initializers/rolemodel_generators.rb`. + +## How it works + +The seeder checks for known file artifacts that each generator creates — for +example, `webpack.config.js` for the webpack generator, +`config/initializers/sentry.rb` for sentry, `Procfile` for heroku. When it +finds one, it writes a `g.rolemodel : true` entry with a `seeded-by-detection` +comment. + +Entries from genuine generator runs (carrying a `rolemodel_rails X.Y.Z` version +stamp) are **never overwritten**. The seeder also never writes `false` entries. + +## Running it + +``` +rails generate rolemodel:registry +``` + +Review the output carefully. Any generator that shows "not detected" may still +have been applied (some generators leave no reliable file footprint). Check +those manually and add entries by hand if needed. + +## Re-running + +Re-running is a no-op that reports current state. Already-seeded entries are +skipped; genuinely recorded entries are untouched. diff --git a/lib/generators/rolemodel/registry/USAGE b/lib/generators/rolemodel/registry/USAGE new file mode 100644 index 00000000..d4109914 --- /dev/null +++ b/lib/generators/rolemodel/registry/USAGE @@ -0,0 +1,15 @@ +Description: + Seed the rolemodel_rails generator registry for an existing app. Scans + for characteristic output files of previously applied generators and + writes detection-based entries into config/initializers/rolemodel_generators.rb. + + Safe to run multiple times — never overwrites existing version-stamped + entries and never writes false entries. + +Example: + rails generate rolemodel:registry + + This will scan your app for signs of previously applied RoleModel generators + (webpack, sentry, simple_form, etc.) and create or update the registry + initializer with seeded entries. Review the output for any "not detected" + generators that you know were applied and adjust the initializer manually. diff --git a/lib/generators/rolemodel/registry/registry_generator.rb b/lib/generators/rolemodel/registry/registry_generator.rb new file mode 100644 index 00000000..2f7c704d --- /dev/null +++ b/lib/generators/rolemodel/registry/registry_generator.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module Rolemodel + # One-time seeding generator for consuming apps that predate the registry. + # Feature-detects which generators have been applied by probing for their + # characteristic output files, then writes seeded entries into the + # config/initializers/rolemodel_generators.rb managed block. + # + # Safe to run multiple times: never overwrites existing entries, never writes + # false. A re-run simply reports current state and no-ops. + # + # Exempt from registry recording — it bootstraps the registry, it is not + # itself a product-of-installation generator. + class RegistryGenerator < GeneratorBase + skip_registry_entry! + + # Map from registry key → detection probe. Each entry is a lambda that + # receives the app's root and returns true if that generator's footprint + # is detected. + # + # The probes derive from each generator's idempotency guards and + # characteristic output files. Low-confidence probes are omitted rather + # than risk false positives; those generators will show as "not detected" + # and the human can decide. + DETECTION_MAP = { + webpack: ->(root) { File.exist?(File.join(root, 'webpack.config.js')) }, + sentry: ->(root) { File.exist?(File.join(root, 'config/initializers/sentry.rb')) }, + simple_form: ->(root) { File.exist?(File.join(root, 'config/initializers/simple_form.rb')) }, + good_job: ->(root) { File.exist?(File.join(root, 'config/initializers/good_job.rb')) }, + lograge: ->(root) { File.exist?(File.join(root, 'config/initializers/lograge.rb')) }, + slim: ->(root) { File.exist?(File.join(root, 'app/views/layouts/application.html.slim')) }, + github: ->(root) { Dir.exist?(File.join(root, '.github/workflows')) }, + heroku: ->(root) { File.exist?(File.join(root, 'Procfile')) }, + tailored_select: ->(root) { File.exist?(File.join(root, 'app/inputs/tailored_select_input.rb')) }, + react: ->(root) { File.exist?(File.join(root, 'app/javascript/controllers/react_controller.js')) }, + editors: ->(root) { + f = File.join(root, '.vscode/extensions.json') + File.exist?(f) && File.read(f).include?('EditorConfig') + }, + kaminari: ->(root) { Dir.exist?(File.join(root, 'app/views/kaminari')) }, + mailers: ->(root) { File.exist?(File.join(root, 'config/initializers/premailer_rails.rb')) }, + soft_destroyable: ->(root) { File.exist?(File.join(root, 'app/models/concerns/soft_destroyable.rb')) }, + source_map: ->(root) { File.exist?(File.join(root, 'lib/middleware/rolemodel/source_map.rb')) }, + optics_base: ->(root) { + scss = Dir.glob(File.join(root, 'app/assets/stylesheets/application.*')).first + scss && File.read(scss).include?('@rolemodel/optics') + }, + optics_icons: ->(root) { File.exist?(File.join(root, 'app/helpers/icon_helper.rb')) }, + testing_rspec: ->(root) { File.exist?(File.join(root, 'spec/spec_helper.rb')) }, + testing_factory_bot: ->(root) { File.exist?(File.join(root, 'spec/support/factory_bot.rb')) }, + testing_parallel_tests: ->(root) { File.exist?(File.join(root, '.rspec_parallel')) }, + testing_vitest: ->(root) { File.exist?(File.join(root, 'vitest.config.js')) }, + testing_jasmine_playwright: ->(root) { File.exist?(File.join(root, 'jp-runner.config.mjs')) }, + saas_devise: ->(root) { File.exist?(File.join(root, 'config/initializers/devise.rb')) }, + linters_eslint: ->(root) { File.exist?(File.join(root, 'eslint.config.js')) }, + linters_rubocop: ->(root) { File.exist?(File.join(root, '.rubocop.yml')) }, + ui_components_flash: ->(root) { File.exist?(File.join(root, 'app/views/application/_flash.html.slim')) }, + ui_components_modals: ->(root) { File.exist?(File.join(root, 'app/javascript/initializers/turbo_confirm.js')) }, + ui_components_navbar: ->(root) { File.exist?(File.join(root, 'app/views/layouts/_navbar.html.slim')) }, + }.freeze + + def detect_and_seed + say 'Scanning for previously applied generators…', :blue + + # Ensure the initializer file and managed block exist + create_initializer + + say '', :blue + + seeded = 0 + skipped = 0 + not_detected = 0 + + DETECTION_MAP.each do |key, probe| + detected = probe.call(destination_root) + + if already_recorded?(key) + skipped += 1 + say " #{key.to_s.ljust(30)} already recorded — skipping", :cyan + next + end + + if detected + Registry.record(key, destination_root:, comment: 'seeded-by-detection') + seeded += 1 + say " #{key.to_s.ljust(30)} detected & seeded", :green + else + not_detected += 1 + say " #{key.to_s.ljust(30)} not detected", :yellow + end + end + + initializer_path = File.expand_path(Registry::INITIALIZER_PATH, destination_root) + + say '', :green + say "Seeded: #{seeded} | Skipped (already recorded): #{skipped} | Not detected: #{not_detected}", :green + say '', :blue + say "Review #{initializer_path} in your app and adjust entries as needed.", :blue + end + + private + + # Create the initializer from the template if it doesn't already exist. + def create_initializer + path = File.expand_path(Registry::INITIALIZER_PATH, destination_root) + return if File.exist?(path) + + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, Registry::FILE_TEMPLATE) + say ' Created empty registry initializer', :blue + end + + # Check if a key already has a genuine (version-stamped) entry in the + # managed block. A seeded-by-detection entry is NOT considered "already + # recorded" for the purpose of skip logic — the seeder overwrites its own + # prior seeds to keep the comment clean. A version-stamped entry (from a + # genuine generator run) is never touched. + def already_recorded?(key) + path = File.expand_path(Registry::INITIALIZER_PATH, destination_root) + return false unless File.exist?(path) + + content = File.read(path) + return false unless content.include?(Registry::BEGIN_MARKER) + + content.lines.each do |line| + match = Registry::ENTRY_PATTERN.match(line) + next unless match && match[:key] == key.to_s + + # Only skip if the entry has a version stamp (genuine run), + # not a seeded-by-detection comment + return line.include?('rolemodel_rails') + end + + false + end + end +end diff --git a/spec/generators/rolemodel/registry_generator_spec.rb b/spec/generators/rolemodel/registry_generator_spec.rb new file mode 100644 index 00000000..2c9d7fe7 --- /dev/null +++ b/spec/generators/rolemodel/registry_generator_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +RSpec.describe Rolemodel::RegistryGenerator, type: :generator do + include ExampleApp + + let(:initializer_path) { File.expand_path(Rolemodel::Registry::INITIALIZER_PATH, destination_root) } + + before { prepare_test_app } + + context 'with no existing registry file' do + it 'creates the initializer and seeds detected generators' do + expect(File.exist?(initializer_path)).to be(false) + + run_generator_against_test_app + + expect(File.exist?(initializer_path)).to be(true) + content = File.read(initializer_path) + expect(content).to include(Rolemodel::Registry::BEGIN_MARKER) + expect(content).to include(Rolemodel::Registry::END_MARKER) + + # The example app has webpack config and a Procfile (from heroku setup) + # so those should be detected + expect(content).to include('seeded-by-detection') + end + end + + context 'with an existing registry file that has a genuine entry' do + before do + # Simulate a genuine registry entry with a version stamp + FileUtils.mkdir_p(File.dirname(initializer_path)) + File.write(initializer_path, <<~RUBY) + # frozen_string_literal: true + + Rails.application.config.generators do |g| + #{Rolemodel::Registry::BEGIN_MARKER} + g.rolemodel webpack: true # rolemodel_rails 1.0.0, 2026-01-01 + #{Rolemodel::Registry::END_MARKER} + end + RUBY + end + + it 'never overwrites a genuine version-stamped entry' do + run_generator_against_test_app + + content = File.read(initializer_path) + # The existing webpack entry with its version stamp should be untouched + expect(content).to include('g.rolemodel webpack: true # rolemodel_rails 1.0.0, 2026-01-01') + # Other detected generators may be seeded alongside it + expect(content).to include('seeded-by-detection') + end + end + + context 'with a seeded entry from a prior run' do + before do + FileUtils.mkdir_p(File.dirname(initializer_path)) + File.write(initializer_path, <<~RUBY) + # frozen_string_literal: true + + Rails.application.config.generators do |g| + #{Rolemodel::Registry::BEGIN_MARKER} + g.rolemodel sentry: true # seeded-by-detection + #{Rolemodel::Registry::END_MARKER} + end + RUBY + end + + it 'overwrites its own seeded entries to keep the comment clean' do + run_generator_against_test_app + + content = File.read(initializer_path) + # Should still have sentry, but not duplicate it + expect(content.scan(/g\.rolemodel sentry:/).length).to eq(1) + end + end + + context 'with an explicit opt-out entry' do + before do + FileUtils.mkdir_p(File.dirname(initializer_path)) + File.write(initializer_path, <<~RUBY) + # frozen_string_literal: true + + Rails.application.config.generators do |g| + #{Rolemodel::Registry::BEGIN_MARKER} + g.rolemodel webpack: false + #{Rolemodel::Registry::END_MARKER} + end + RUBY + end + + it 'never overwrites a false (opt-out) entry' do + run_generator_against_test_app + + content = File.read(initializer_path) + expect(content).to include('g.rolemodel webpack: false') + # The false entry should still be there, not replaced with true + expect(content).not_to include('g.rolemodel webpack: true') + end + end + + context 're-running on the same app' do + before do + # First run + run_generator_against_test_app + end + + it 'is a no-op that reports current state' do + first_run_content = File.read(initializer_path) + + run_generator_against_test_app + + second_run_content = File.read(initializer_path) + + # Content should not change on re-run + expect(second_run_content).to eq(first_run_content) + end + end + + context 'exemption from registry recording' do + it 'is exempt from registry recording (it is a composite/bootstrap generator)' do + expect(described_class.skip_registry_entry?).to be(true) + end + end +end From 8bbbb1a7de4e0e203ebab42ede422762a7f1bdff Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Sun, 12 Jul 2026 17:43:57 -0500 Subject: [PATCH 21/21] docs: document registry contract, coupling behavior, remove semaphore references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Generator Registry section to root README with opt-out and drift recovery instructions - Remove stale Semaphore links from both READMEs - Document sentry↔webpack coupling on both generator READMEs - Document tailored_select↔simple_form coupling - Add Registry generator to both generator index lists --- README.md | 29 ++++++++++++++++++- lib/generators/rolemodel/README.md | 21 +++++++++++++- lib/generators/rolemodel/sentry/README.md | 12 ++++++++ .../rolemodel/tailored_select/README.md | 13 +++++++++ lib/generators/rolemodel/webpack/README.md | 13 ++++++++- 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0a721eff..aa5f7d30 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,37 @@ You can see complete list of available generators (including those under the Rol bin/rails g ``` +## Generator Registry + +Every rolemodel generator that is run records itself in your app's +`config/initializers/rolemodel_generators.rb`. This registry powers coupling +declarations between generators — for example, the sentry and webpack generators +know about each other through the registry and automatically wire the Sentry +webpack plugin when both are present. + +For apps set up before the registry existed, run the seeding generator: + +```shell +bin/rails g rolemodel:registry +``` + +The seeder probes for each generator's characteristic output files and writes +entries marked as `seeded-by-detection`. It is safe to run multiple times. + +### Opting out + +* **Persistent opt-out:** Set `g.rolemodel : false` in the initializer. + The generator will never re-record over a `false` entry. +* **Per-invocation opt-out:** Pass `--no-` to any generator that declares + a coupling (e.g. `rails g rolemodel:sentry --no-sentry-webpack`). +* **Drift recovery:** If the managed block markers are missing from the + initializer, delete the file and run `rails g rolemodel:registry` to + rebuild it. + ## Generators * [Core Setup](./lib/generators/rolemodel/core_setup) * [Github](./lib/generators/rolemodel/github) -* [Semaphore](./lib/generators/rolemodel/semaphore) * [Heroku](./lib/generators/rolemodel/heroku) * [Readme](./lib/generators/rolemodel/readme) * [Webpack](./lib/generators/rolemodel/webpack) @@ -109,6 +135,7 @@ bin/rails g * [Editors](./lib/generators/rolemodel/editors) * [Tailored Select](./lib/generators/rolemodel/tailored_select) * [Lograge](./lib/generators/rolemodel/lograge) +* [Registry](./lib/generators/rolemodel/registry) ## Development diff --git a/lib/generators/rolemodel/README.md b/lib/generators/rolemodel/README.md index d4583568..8dfb059f 100644 --- a/lib/generators/rolemodel/README.md +++ b/lib/generators/rolemodel/README.md @@ -13,13 +13,32 @@ * [Optics](./optics) * [README](./readme) * [SaaS](./saas) -* [Semaphore](./semaphore) * [SimpleForm](./simple_form) * [Slim](./slim) * [SoftDestroyable](./soft_destroyable) * [Source Map](./source_map) * [Testing](./testing) * [Webpack](./webpack) +* [Registry](./registry) + +## Generator Registry + +Each generator records itself in `config/initializers/rolemodel_generators.rb` +so that other generators can detect whether it has been applied. See +[Registry](./registry) for the seeding tool. + +### Generator coupling + +Some generators declare optional couplings: + +* **sentry ↔ webpack:** Whichever is installed second wires the Sentry webpack + plugin into `webpack.config.js` via the `sentry_webpack` hook sub-generator. + Pass `--no-sentry-webpack` to suppress. Set + `g.rolemodel sentry_webpack: false` in the initializer for a persistent opt-out. +* **tailored_select ↔ simple_form:** Installing simple_form with + `--tailored-select` installs the tailored_select package and its input. + Installing tailored_select standalone installs the package without the input. + Pass `--simple-form-input` to force the input regardless. ## Helpful documentation diff --git a/lib/generators/rolemodel/sentry/README.md b/lib/generators/rolemodel/sentry/README.md index caff93d3..3c5df4b0 100644 --- a/lib/generators/rolemodel/sentry/README.md +++ b/lib/generators/rolemodel/sentry/README.md @@ -18,6 +18,18 @@ Error monitoring and performance tracing via [Sentry](https://sentry.io) for bot Depends on the `rolemodel:webpack` generator having already created `webpack.config.js` and `app/javascript/application.js`. +## Coupling with webpack + +When `rolemodel:webpack` is already recorded in the app's generator registry, +running the sentry generator automatically wires the `sentryWebpackPlugin` into +`webpack.config.js` via the shared `rolemodel:sentry_webpack` hook sub-generator. + +* Pass `--no-sentry-webpack` to suppress this wiring. +* Set `g.rolemodel sentry_webpack: false` in `config/initializers/rolemodel_generators.rb` + for a persistent opt-out. +* If webpack is installed *after* sentry, running `rolemodel:webpack` will + likewise wire the Sentry plugin automatically. + ## After running * Update the `project` and `applicationKey` in `webpack.config.js`, and the matching `filterKeys` in `app/javascript/initializers/sentry.js`, to match your Sentry project. diff --git a/lib/generators/rolemodel/tailored_select/README.md b/lib/generators/rolemodel/tailored_select/README.md index 20a7d26d..8184ac87 100644 --- a/lib/generators/rolemodel/tailored_select/README.md +++ b/lib/generators/rolemodel/tailored_select/README.md @@ -5,3 +5,16 @@ ## What you get The [Tailored Select](https://github.com/RoleModel/tailored-select) web component + +## Coupling with simple_form + +When `rolemodel:simple_form` is recorded in the app's generator registry, +the tailored_select generator installs its SimpleForm input template +(`app/inputs/tailored_select_input.rb`) automatically. + +* Standalone install (without simple_form): installs only the JS package. +* Pass `--simple-form-input` to force the input installation regardless of + registry state. +* Pass `--no-simple-form-input` to suppress it. +* The simple_form generator can delegate this explicitly with + `--tailored-select`, which forces the input during that child run. diff --git a/lib/generators/rolemodel/webpack/README.md b/lib/generators/rolemodel/webpack/README.md index ce7551c4..f621bc48 100644 --- a/lib/generators/rolemodel/webpack/README.md +++ b/lib/generators/rolemodel/webpack/README.md @@ -6,4 +6,15 @@ * Uses `esbuild-loader` instead of Babel to transpile JS * Uses PostCSS to compile CSS and SCSS -For error monitoring, run the `rolemodel:sentry` generator. +## Coupling with sentry + +When `rolemodel:sentry` is already recorded in the app's generator registry, +running the webpack generator automatically wires the `sentryWebpackPlugin` into +the freshly created `webpack.config.js` via the shared `rolemodel:sentry_webpack` +hook sub-generator. + +* Pass `--no-sentry-webpack` to suppress this wiring. +* Set `g.rolemodel sentry_webpack: false` in `config/initializers/rolemodel_generators.rb` + for a persistent opt-out. +* If sentry is installed *after* webpack, running `rolemodel:sentry` will + likewise wire the Sentry plugin automatically.