From 916c08d9f48df5f43971ac380525ce3edf09e990 Mon Sep 17 00:00:00 2001 From: Steve Polito Date: Fri, 22 Mar 2024 14:35:30 -0400 Subject: [PATCH] Introduce `suspenders:ci` generator Creates CI template to be run via [GitHub Actions][ga] based on a [similar template][ci template] that will be generated in an upcoming Release of Rails. Also create a [Dependabot][dependabot] file based off the [the upcoming release][ci template]. Raises if the application is not using PostgreSQL, since our CI template assumes that adapter. Because this generator can be run in an existing application, we add conditional checks for some jobs. However, this generator is intended to be run as part of our holistic `suspenders:install:web` which will be introduced in #1152. Once Rails is released to contain a CI template, we will need to consider how we want to handle conflicts between its file and ours, but for now, we do not need to worry about that. [ga]: https://docs.github.com/en/actions [ci template]: https://github.com/rails/rails/pull/50508 [dependabot]: https://docs.github.com/en/code-security/dependabot/working-with-dependabot --- NEWS.md | 1 + README.md | 8 + lib/generators/suspenders/ci_generator.rb | 51 ++++++ lib/generators/templates/ci/ci.yml.tt | 148 ++++++++++++++++++ lib/generators/templates/ci/dependabot.yml | 7 + lib/suspenders/generators.rb | 28 ++++ .../suspenders/ci_generator_test.rb | 40 +++++ test/suspenders/generators_test.rb | 8 + test/test_helper.rb | 12 ++ 9 files changed, 303 insertions(+) create mode 100644 lib/generators/suspenders/ci_generator.rb create mode 100644 lib/generators/templates/ci/ci.yml.tt create mode 100644 lib/generators/templates/ci/dependabot.yml create mode 100644 test/generators/suspenders/ci_generator_test.rb diff --git a/NEWS.md b/NEWS.md index 7831295c6..1323a5fe5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -16,6 +16,7 @@ Unreleased * Introduce `suspenders:email` generator * Introduce `suspenders:testing` generator * Introduce `suspenders:prerequisites` generator +* Introduce `suspenders:ci` generator 20230113.0 (January, 13, 2023) diff --git a/README.md b/README.md index 4876d7085..54141cd6e 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,14 @@ Configures prerequisites. Currently Node. bin/rails g suspenders:prerequisites ``` +### CI + +CI + +``` +bin/rails g suspenders:ci +``` + ## Contributing See the [CONTRIBUTING] document. diff --git a/lib/generators/suspenders/ci_generator.rb b/lib/generators/suspenders/ci_generator.rb new file mode 100644 index 000000000..957a79c49 --- /dev/null +++ b/lib/generators/suspenders/ci_generator.rb @@ -0,0 +1,51 @@ +module Suspenders + module Generators + class CiGenerator < Rails::Generators::Base + include Suspenders::Generators::DatabaseUnsupported + include Suspenders::Generators::Helpers + + source_root File.expand_path("../../templates/ci", __FILE__) + + def ci_files + empty_directory ".github/workflows" + template "ci.yml", ".github/workflows/ci.yaml" + template "dependabot.yml", ".github/dependabot.yaml" + end + + private + + def scan_ruby? + has_gem? "bundler-audit" + end + + def scan_js? + File.exist?("bin/importmap") && using_node? + end + + def lint? + using_node? && has_gem?("standard") && has_yarn_script?("lint") + end + + def using_node? + File.exist? "package.json" + end + + def has_gem?(name) + Bundler.rubygems.find_name(name).any? + end + + def using_rspec? + File.exist? "spec" + end + + def has_yarn_script?(name) + return false if !using_node? + + content = File.read("package.json") + json = JSON.parse(content) + + json.dig("scripts", name) + end + end + end +end diff --git a/lib/generators/templates/ci/ci.yml.tt b/lib/generators/templates/ci/ci.yml.tt new file mode 100644 index 000000000..10d85a904 --- /dev/null +++ b/lib/generators/templates/ci/ci.yml.tt @@ -0,0 +1,148 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: +<%- if scan_ruby? -%> + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for security vulnerabilities in Ruby dependencies + run: | + bin/rails bundle:audit:update + bin/rails bundle:audit +<% end -%> + +<%- if scan_js? -%> + scan_js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + + - name: Install modules + run: yarn install + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: | + bin/importmap audit + yarn audit +<% end -%> + +<%- if lint? -%> + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + + - name: Install modules + run: yarn install + + - name: Lint Ruby code for consistent style + run: bin/rails standard + + - name: Lint front-end code for consistent style + run: yarn lint +<% end -%> + + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + # redis: + # image: redis + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libvips postgresql-client libpq-dev + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + <%- if using_node? -%> + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + + - name: Install modules + run: yarn install + <%- end -%> + + - name: Run tests + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + # REDIS_URL: redis://localhost:6379/0 + <%- if using_rspec? -%> + run: bin/rails db:setup spec + <%- else -%> + run: bin/rails db:setup test test:system + <%- end -%> + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + <%- if using_rspec? -%> + path: ${{ github.workspace }}/tmp/capybara + <%- else -%> + path: ${{ github.workspace }}/tmp/screenshots + <%- end -%> + if-no-files-found: ignore diff --git a/lib/generators/templates/ci/dependabot.yml b/lib/generators/templates/ci/dependabot.yml new file mode 100644 index 000000000..452ebb342 --- /dev/null +++ b/lib/generators/templates/ci/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/lib/suspenders/generators.rb b/lib/suspenders/generators.rb index cbefec2bf..a7228ae1a 100644 --- a/lib/suspenders/generators.rb +++ b/lib/suspenders/generators.rb @@ -44,5 +44,33 @@ def api_only_app? .match?(/^\s*config\.api_only\s*=\s*true/i) end end + + module DatabaseUnsupported + class Error < StandardError + def message + "This generator requires PostgreSQL" + end + end + + extend ActiveSupport::Concern + + included do + def raise_if_database_unsupported + if database_unsupported? + raise Suspenders::Generators::DatabaseUnsupported::Error + end + end + + private + + def database_unsupported? + configuration = File.read(Rails.root.join("config/database.yml")) + configuration = YAML.load(configuration, aliases: true) + adapter = configuration["default"]["adapter"] + + adapter != "postgresql" + end + end + end end end diff --git a/test/generators/suspenders/ci_generator_test.rb b/test/generators/suspenders/ci_generator_test.rb new file mode 100644 index 000000000..3d6f96011 --- /dev/null +++ b/test/generators/suspenders/ci_generator_test.rb @@ -0,0 +1,40 @@ +require "test_helper" +require "generators/suspenders/ci_generator" + +module Suspenders + module Generators + class CiGeneratorTest < Rails::Generators::TestCase + include Suspenders::TestHelpers + + tests Suspenders::Generators::CiGenerator + destination Rails.root + teardown :restore_destination + + test "generates CI files" do + with_database "postgresql" do + run_generator + + assert_file app_root(".github/workflows/ci.yaml") + assert_file app_root(".github/dependabot.yaml") + end + end + + test "raises if PostgreSQL is not the adapter" do + with_database "unsupported" do + assert_raises Suspenders::Generators::DatabaseUnsupported::Error, match: "This generator requires PostgreSQL" do + run_generator + + assert_no_file app_root(".github/workflows/ci.yaml") + assert_no_file app_root(".github/dependabot.yaml") + end + end + end + + private + + def restore_destination + remove_dir_if_exists ".github" + end + end + end +end diff --git a/test/suspenders/generators_test.rb b/test/suspenders/generators_test.rb index 3d7cccb62..5c2f56320 100644 --- a/test/suspenders/generators_test.rb +++ b/test/suspenders/generators_test.rb @@ -8,4 +8,12 @@ class APIAppUnsupportedTest < Suspenders::GeneratorsTest assert_equal expected, Suspenders::Generators::APIAppUnsupported::Error.new.message end end + + class DatabaseUnsupportedTest < Suspenders::GeneratorsTest + test "message returns a custom message" do + expected = "This generator requires PostgreSQL" + + assert_equal expected, Suspenders::Generators::DatabaseUnsupported::Error.new.message + end + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index b0670b416..c34022698 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -100,6 +100,18 @@ def with_test_suite(test_suite, &block) remove_dir_if_exists "spec" end + def with_database(database, &block) + backup_file "config/database.yml" + configuration = File.read app_root("config/database.yml") + configuration = YAML.load(configuration, aliases: true) + configuration["default"]["adapter"] = database + File.open(app_root("config/database.yml"), "w") { _1.write configuration.to_yaml } + + yield + ensure + restore_file "config/database.yml" + end + def backup_file(file) FileUtils.copy app_root(file), app_root("#{file}.bak") end