diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec9d0de4..bde94d00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: '.node-version' cache: 'yarn' @@ -122,7 +122,6 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - name: Checkout this repo uses: actions/checkout@v4 @@ -141,7 +140,7 @@ jobs: sudo ln -s "$PWD/actionlint" /usr/local/bin/actionlint - name: Install NodeJS - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: cache: 'yarn' node-version-file: '.node-version' diff --git a/.rubocop.yml b/.rubocop.yml index 577cd44c..1cff886d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,9 +16,24 @@ Rails: Enabled: true AllCops: - # Target the oldest version of Ruby that Rails 7 supports - TargetRubyVersion: 2.7 - TargetRailsVersion: 7.1 + # Target the oldest Ruby that this template's chosen Rails version supports In + # a normal Rails app, Rubocop would infer this from the version of Ruby + # running. We want the rubocop checks that run on this repo to work for any + # version of Ruby that the chosen Rails version supports so we have to be + # explicit. + TargetRubyVersion: + <%= + YAML.safe_load(Pathname.new("./target_versions.yml").realpath.read).fetch("minimum_ruby_major_minor") + %> + + # In a normal Rails app, Rubocop would pull this from the Gemfile. We want the + # rubocop checks that run on this repo to be consistent with the checks that + # run on generated apps so we have to be explicit. + TargetRailsVersion: + <%= + YAML.safe_load(Pathname.new("./target_versions.yml").realpath.read).fetch("target_rails_major_minor") + %> + NewCops: enable DisplayCopNames: true DisplayStyleGuide: true @@ -210,9 +225,6 @@ Rails/Output: Style/BarePercentLiterals: EnforcedStyle: percent_q -Style/ClassAndModuleChildren: - Enabled: false - Style/DoubleNegation: Enabled: false diff --git a/Gemfile.lock b/Gemfile.lock index e0219bfc..a461be92 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,8 +32,8 @@ GEM rack (3.0.9.1) rainbow (3.1.1) regexp_parser (2.9.0) - rexml (3.2.8) - strscan (>= 3.0.9) + rexml (3.3.6) + strscan rubocop (1.60.2) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -73,6 +73,7 @@ GEM PLATFORMS arm64-darwin-21 arm64-darwin-22 + arm64-darwin-23 x86_64-linux DEPENDENCIES diff --git a/README.md b/README.md index e46370d8..a4267a35 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This is a template you can use to create new Rails applications. - [How do I use this?](#how-do-i-use-this) - [How do I use this template for every Rails app I create?](#how-do-i-use-this-template-for-every-rails-app-i-create) - [Contributing](#contributing) + - [Updating this template to a new Rails version](#updating-this-template-to-a-new-rails-version) - [Credits](#credits) ## Background @@ -287,6 +288,13 @@ $ bundle install $ bundle exec rubocop # optionally adding -A for autofixes ``` +### Updating this template to a new Rails version + +1. Change the Rails and Ruby versions in + [./target_versions.yml](./target_versions.yml) as per the instructions in + that file. +2. Update the template as required to support the new Rails version + ## Credits This repo was forked from diff --git a/ci/bin/build-and-test b/ci/bin/build-and-test index 247d46c8..7e950ae7 100755 --- a/ci/bin/build-and-test +++ b/ci/bin/build-and-test @@ -1,12 +1,28 @@ #!/usr/bin/env ruby require "fileutils" +require "pathname" +require "yaml" + +def ensure_everything_is_committed + out = `git status -s` + + raise StandardError, "there are uncommitted changes or un-staged files: #{out}" unless out.empty? +end + +def build_rails_version_specifier(target_versions_path) + major_minor = YAML.safe_load(File.read(target_versions_path)).fetch("target_rails_major_minor") + + "~> #{major_minor}.0" +end root_path = File.absolute_path(File.join(__dir__, "../..")) template_path = File.join(root_path, "template.rb") builds_path = File.join(root_path, "tmp/builds") app_name = ENV.fetch("APP_NAME", "app") app_path = File.join(builds_path, app_name) +target_versions_path = File.join(root_path, "target_versions.yml") +rails_version_specifier = build_rails_version_specifier(target_versions_path) config_path = if ENV.fetch("CONFIG_PATH", "") == "" File.join(root_path, "ackama_rails_template.config.yml") @@ -32,8 +48,19 @@ puts "=" * 80 raise "Missing config YML file" if config_path && !(File.exist?(config_path) && File.file?(config_path)) -puts "Installing latest rails gem" -system "gem install rails --no-document" +puts "Installing latest compatible rails gem" +install_output = `gem install rails --version '#{rails_version_specifier}' --no-document` + +puts install_output # for ease of debugging + +rails_version = install_output + .split("\n") + .grep(/Successfully installed rails-\d/) + .first + .strip + .sub("Successfully installed rails-", "") + +puts "Using rails version #{rails_version}" unless Dir.exist?(builds_path) puts "Creating #{builds_path}" @@ -47,7 +74,7 @@ end Dir.chdir(builds_path) do |cwd| puts "Working dir is now #{cwd}" - cmd = %Q(#{config_env_var} RACK_ENV=development RAILS_ENV=development rails new "#{app_name}" -d postgresql #{skip_flags} -m "#{template_path}") + cmd = %Q(#{config_env_var} TARGET_VERSIONS_PATH="#{target_versions_path}" RACK_ENV=development RAILS_ENV=development rails _#{rails_version}_ new "#{app_name}" -d postgresql #{skip_flags} -m "#{template_path}") # rubocop:disable Layout/LineLength puts <<~EO_HELP Build command: #{cmd} @@ -56,12 +83,6 @@ Dir.chdir(builds_path) do |cwd| system(cmd, exception: true) end -def ensure_everything_is_committed - out = `git status -s` - - raise StandardError, "there are uncommitted changes or un-staged files: #{out}" unless out.empty? -end - puts "Running post-generator checks" Dir.chdir(app_path) do |cwd| puts "Working dir is now #{cwd}" diff --git a/target_versions.yml b/target_versions.yml new file mode 100644 index 00000000..ee1f6e13 --- /dev/null +++ b/target_versions.yml @@ -0,0 +1,8 @@ +# This template will work with Rails versions that match the given major.minor +# +# This is stored in a separate file so we can share it between the template and +# our CI configuration. +target_rails_major_minor: '7.1' # specify as major.minor + +# Set this to the minimum version of Ruby that the chosen Rails version supports. +minimum_ruby_major_minor: '2.7' diff --git a/template.rb b/template.rb index eda21fbe..56aec823 100644 --- a/template.rb +++ b/template.rb @@ -2,19 +2,21 @@ require "shellwords" require "pp" -RAILS_REQUIREMENT = "~> 7.1.1".freeze - ## # This single template file will be downloaded and run by the `rails new` # command so all code it needs must be inlined we cannot load other files from # this repo. # class Config - DEFAULT_CONFIG_FILE_PATH = "../ackama_rails_template.config.yml".freeze + DEFAULT_CONFIG_PATH = "../ackama_rails_template.config.yml".freeze + DEFAULT_TARGET_VERSIONS_PATH = "../target_versions.yml".freeze + + attr_reader :acceptable_rails_versions_specifier def initialize - config_file_path = File.absolute_path(ENV.fetch("CONFIG_PATH", DEFAULT_CONFIG_FILE_PATH)) + config_file_path = File.absolute_path(ENV.fetch("CONFIG_PATH", DEFAULT_CONFIG_PATH)) @yaml_config = YAML.safe_load(File.read(config_file_path)) + @acceptable_rails_versions_specifier = load_acceptable_rails_versions_specifier end def staging_hostname @@ -60,6 +62,22 @@ def apply_variant_deploy_with_capistrano? def apply_variant_deploy_with_ackama_ec2_capistrano? @yaml_config.fetch("apply_variant_deploy_with_ackama_ec2_capistrano") end + + private + + ## + # Rails version constraint is stored in a separate file so that it can be used + # outside of just the template + # + def load_acceptable_rails_versions_specifier + rel_path = ENV.fetch("TARGET_VERSIONS_PATH", DEFAULT_TARGET_VERSIONS_PATH) + abs_path = Pathname.new(rel_path).realpath + major_minor = YAML.safe_load(abs_path.read).fetch("target_rails_major_minor") + + # Use the major.minor we got from the YAML file to build a RubyGems + # compatible specifier that will match all patch versions + "~> #{major_minor}.0" + end end class Terminal @@ -306,11 +324,11 @@ def add_template_repository_to_source_path end def assert_minimum_rails_version - requirement = Gem::Requirement.new(RAILS_REQUIREMENT) + requirement = Gem::Requirement.new(TEMPLATE_CONFIG.acceptable_rails_versions_specifier) rails_version = Gem::Version.new(Rails::VERSION::STRING) return if requirement.satisfied_by?(rails_version) - puts "ERROR: This template requires Rails #{RAILS_REQUIREMENT}. You are using #{rails_version}" + puts "ERROR: This template requires Rails #{TEMPLATE_CONFIG.acceptable_rails_versions_specifier}. You are using #{rails_version}" exit 1 end diff --git a/variants/backend-base/Gemfile.tt b/variants/backend-base/Gemfile.tt index cecab073..5dddbee0 100644 --- a/variants/backend-base/Gemfile.tt +++ b/variants/backend-base/Gemfile.tt @@ -6,7 +6,7 @@ ruby File.read(".ruby-version") gem "rails", "<%= Rails.version %>" gem "puma" gem "pg" -gem 'dotenv-rails', require: "dotenv/rails-now" +gem 'dotenv-rails', require: "dotenv/load" gem "bootsnap", require: false gem "shakapacker" diff --git a/variants/backend-base/app/controllers/active_storage/base_controller.rb b/variants/backend-base/app/controllers/active_storage/base_controller.rb index 3dd06016..d4ccae30 100644 --- a/variants/backend-base/app/controllers/active_storage/base_controller.rb +++ b/variants/backend-base/app/controllers/active_storage/base_controller.rb @@ -4,23 +4,25 @@ # Copied from # https://github.com/rails/rails/blob/main/activestorage/app/controllers/active_storage/base_controller.rb # :nocov: -class ActiveStorage::BaseController < ActionController::Base # rubocop:disable Rails/ApplicationController - include ActiveStorage::SetCurrent +module ActiveStorage + class BaseController < ActionController::Base # rubocop:disable Rails/ApplicationController + include ActiveStorage::SetCurrent - protect_from_forgery with: :exception + protect_from_forgery with: :exception - before_action :authenticated? + before_action :authenticated? - self.etag_with_template_digest = false + self.etag_with_template_digest = false - private + private - # ActiveStorage defaults to security via obscurity approach to serving links - # If this is acceptable for your use case then this authenticable test can be - # removed. If not then code should be added to only serve files appropriately. - # https://edgeguides.rubyonrails.org/active_storage_overview.html#proxy-mode - def authenticated? - raise StandardError, "No authentication is configured for ActiveStorage" + # ActiveStorage defaults to security via obscurity approach to serving links + # If this is acceptable for your use case then this authenticable test can be + # removed. If not then code should be added to only serve files appropriately. + # https://edgeguides.rubyonrails.org/active_storage_overview.html#proxy-mode + def authenticated? + raise StandardError, "No authentication is configured for ActiveStorage" + end end end # :nocov: diff --git a/variants/backend-base/config/app.yml b/variants/backend-base/config/app.yml index 1734684b..3fa7c94f 100644 --- a/variants/backend-base/config/app.yml +++ b/variants/backend-base/config/app.yml @@ -1,18 +1,29 @@ # Be sure to restart your server when you modify this file. # -# Use this file to load non-sensitive app config from ENV. Config values here -# will be loaded into `Rails.application.config.app`. -# -# Sensitive config should be put in `config/secrets.yml` (which will load it -# into `Rails.application.secrets`) +# Use this file to load configuration values from the environment, which will +# be accessible by the app through `Rails.application.config.app` + +default: &default + # Your secret key is used for verifying the integrity of signed cookies. + # If you change this key, all old signed cookies will become invalid! + # Make sure the secret is at least 30 characters and all random, + # no regular words, or you'll be exposed to dictionary attacks. + # You can use `rails secret` to generate a secure secret key. + secret_key_base: "<%= ENV.fetch('RAILS_SECRET_KEY_BASE') %>" -default: - &default # The default `From:` address to use for email sent by this application + # The default `From:` address to use for email sent by this application mail_from: "<%= ENV['MAIL_FROM'] %>" # this should either begin with GTM- (for a container) or G- (for a tag) google_analytics_id: "<%= ENV.fetch('GOOGLE_ANALYTICS_ID', nil) %>" + active_record_encryption_primary_key: + "<%= ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') %>" + active_record_encryption_deterministic_key: + "<%= ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') %>" + active_record_encryption_key_derivation_salt: + "<%= ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') %>" + development: <<: *default diff --git a/variants/backend-base/config/application.rb b/variants/backend-base/config/application.rb index b4eabfd4..24cea88d 100644 --- a/variants/backend-base/config/application.rb +++ b/variants/backend-base/config/application.rb @@ -17,17 +17,17 @@ # load config/app.yml into Rails.application.config.app.* config.app = config_for(:app) + # pull the secret_key_base from our app config + config.secret_key_base = config.app.secret_key_base + config.middleware.insert_before Rack::Sendfile, HttpBasicAuth config.action_dispatch.default_headers["Permissions-Policy"] = "interest-cohort=()" - # ActiveRecord encrypted attributes expectes to find the key secrets under - # `config.active_record.encryption.*`. If the secrets were stored in Rails - # encrypted credentials file then Rails would map them automatically for us. - # We prefer to store the secrets in the ENV and load them through - # `config/secrets.yml` so we have to manually assign them here. - config.active_record.encryption.primary_key = Rails.application.secrets.active_record_encryption_primary_key - config.active_record.encryption.deterministic_key = Rails.application.secrets.active_record_encryption_deterministic_key - config.active_record.encryption.key_derivation_salt = Rails.application.secrets.active_record_encryption_key_derivation_salt + # Configure the encryption key for ActiveRecord encrypted attributes with values from our app config, + # as Rails only automatically picks them up if they're sourced from `config/credentials.yml.enc` + config.active_record.encryption.primary_key = Rails.application.config.app.active_record_encryption_primary_key + config.active_record.encryption.deterministic_key = Rails.application.config.app.active_record_encryption_deterministic_key + config.active_record.encryption.key_derivation_salt = Rails.application.config.app.active_record_encryption_key_derivation_salt config.action_dispatch.default_headers["X-Frame-Options"] = "DENY" diff --git a/variants/backend-base/config/initializers/content_security_policy.rb b/variants/backend-base/config/initializers/content_security_policy.rb index 756be0db..e0c53604 100644 --- a/variants/backend-base/config/initializers/content_security_policy.rb +++ b/variants/backend-base/config/initializers/content_security_policy.rb @@ -49,9 +49,9 @@ # `*_src` method. # # asset_host = if Rails.env.development? || Rails.env.test? - # "http://#{Rails.application.secrets.asset_host}" + # "http://#{Rails.application.config.app.asset_host}" # else - # "https://#{Rails.application.secrets.asset_host}" + # "https://#{Rails.application.config.app.asset_host}" # end # policy.default_src :self, asset_host diff --git a/variants/backend-base/config/secrets.yml b/variants/backend-base/config/secrets.yml deleted file mode 100644 index f85d2488..00000000 --- a/variants/backend-base/config/secrets.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Sensitive app config values from ENV should be loaded in this file. -# -# Do NOT put secrets **directly** into this file. All secrets should be loaded -# from ENV! Be sure to restart your server when you modify this file. - -default: &default - # Your secret key is used for verifying the integrity of signed cookies. - # If you change this key, all old signed cookies will become invalid! - # Make sure the secret is at least 30 characters and all random, - # no regular words or you'll be exposed to dictionary attacks. - # You can use `rails secret` to generate a secure secret key. - secret_key_base: "<%= ENV.fetch('RAILS_SECRET_KEY_BASE') %>" - - active_record_encryption_primary_key: - "<%= ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') %>" - active_record_encryption_deterministic_key: - "<%= ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') %>" - active_record_encryption_key_derivation_salt: - "<%= ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') %>" - -development: - <<: *default - -test: - <<: *default - -staging: - <<: *default - -production: - <<: *default diff --git a/variants/backend-base/config/template.rb b/variants/backend-base/config/template.rb index 268333a7..34b622f7 100644 --- a/variants/backend-base/config/template.rb +++ b/variants/backend-base/config/template.rb @@ -2,7 +2,6 @@ template "variants/backend-base/config/database.yml.tt", "config/database.yml", force: true -copy_file "variants/backend-base/config/secrets.yml", "config/secrets.yml", force: true copy_file "variants/backend-base/config/app.yml", "config/app.yml" remove_file "config/master.key" remove_file "config/credentials.yml.enc" diff --git a/variants/backend-base/rubocop.yml.tt b/variants/backend-base/rubocop.yml.tt index 1122c729..d6e6ccf6 100644 --- a/variants/backend-base/rubocop.yml.tt +++ b/variants/backend-base/rubocop.yml.tt @@ -252,9 +252,6 @@ Rails/Output: Style/BarePercentLiterals: EnforcedStyle: percent_q -Style/ClassAndModuleChildren: - Enabled: false - Style/DoubleNegation: Enabled: false diff --git a/variants/devise/template.rb b/variants/devise/template.rb index d0e27339..5b9c2545 100644 --- a/variants/devise/template.rb +++ b/variants/devise/template.rb @@ -111,6 +111,15 @@ append_to_file "app/views/application/_header.html.erb" do <<~ERB + <%= + content_tag(:style, nonce: content_security_policy_nonce) do + <<~STYLE + /* Temp style to pass Lighthouse audit */ + a { display: block; padding: 0.5rem; } + input { padding: 0.5rem; margin: 0.5rem; } + STYLE + end + %>