arkency/rails_event_store

View on GitHub
support/ci/generate

Summary

Maintainability
Test Coverage
#!/usr/bin/env ruby

# frozen_string_literal: true

require "bundler/inline"
require "psych"

gemfile do
  source "https://rubygems.org"
  gem "szczupac", ">= 0.4.0"
end

class CI
  RUBY_VERSIONS = [
    MRI_RUBY = [
      RUBY_3_3 = "ruby-3.3",
      RUBY_3_2 = "ruby-3.2",
      RUBY_3_1 = "ruby-3.1",
      RUBY_3_0 = "ruby-3.0"
    ]
  ].flatten

  DATA_TYPES = [
    DATA_TEXT = "text",
    DATA_BINARY = "binary",
    DATA_JSON = "json",
    DATA_JSONB = "jsonb"
  ]

  DATA_TYPES_IN_AR = [DATA_BINARY, DATA_TYPES.drop(2)].flatten
  DATA_TYPES_IN_SEQUEL = [DATA_TEXT, DATA_TYPES.drop(2)].flatten

  DATABASE_URLS = [
    SQLITE = "sqlite:db.sqlite3",
    SQLITE3 = "sqlite3:db.sqlite3",
    POSTGRES = [
      POSTGRES_15 =
        "postgres://postgres:secret@localhost:10015/rails_event_store",
      POSTGRES_11 =
        "postgres://postgres:secret@localhost:10011/rails_event_store"
    ],
    MYSQL = [
      MYSQL_8 = "mysql2://root:secret@127.0.0.1:10008/rails_event_store",
      MYSQL_5 = "mysql2://root:secret@127.0.0.1:10005/rails_event_store"
    ]
  ].flatten

  GEMFILE = "Gemfile"

  RAILS_GEMFILES = [
    GEMFILE_RAILS_7_0 = "Gemfile.rails_7_0",
    GEMFILE_RAILS_6_1 = "Gemfile.rails_6_1",
    GEMFILE_RAILS_6_0 = "Gemfile.rails_6_0"
  ].flatten

  AS_GEMFILES = [
    GEMFILE_AS_7_0 = "Gemfile.activesupport_7_0",
    GEMFILE_AS_6_1 = "Gemfile.activesupport_6_1",
    GEMFILE_AS_6_0 = "Gemfile.activesupport_6_0"
  ]

  AR_GEMFILES = [
    GEMFILE_AR_7_0 = "Gemfile.activerecord_7_0",
    GEMFILE_AR_6_1 = "Gemfile.activerecord_6_1",
    GEMFILE_AR_6_0 = "Gemfile.activerecord_6_0"
  ]

  SIDEKIQ_GEMFILES = [
    GEMFILE_SIDEKIQ_6_5 = "Gemfile.sidekiq_6_5",
    GEMFILE_SIDEKIQ_5_2 = "Gemfile.sidekiq_5_2"
  ]

  def workflows
    [
      release_test("aggregate_root"),
      release_mutate("aggregate_root"),
      release_coverage("aggregate_root"),
      release_test("ruby_event_store"),
      release_mutate("ruby_event_store"),
      release_coverage("ruby_event_store"),
      release_test("ruby_event_store-rspec"),
      release_mutate("ruby_event_store-rspec"),
      release_coverage("ruby_event_store-rspec"),
      release_test(
        "ruby_event_store-browser",
        steps: [
          checkout,
          verify_lockfile,
          setup_ruby,
          setup_node,
          cache_elm,
          make("install-npm test")
        ],
        matrix:
          generate(
            ruby_version(RUBY_VERSIONS),
            bundle_gemfile(GEMFILE, "Gemfile.rack_2_0")
          )
      ),
      release_mutate("ruby_event_store-browser"),
      release_coverage("ruby_event_store-browser"),
      release_test(
        "rails_event_store",
        matrix:
          generate(
            ruby_version(RUBY_VERSIONS),
            bundle_gemfile(GEMFILE, RAILS_GEMFILES)
          )
      ),
      release_mutate("rails_event_store"),
      release_coverage("rails_event_store"),
      release_test(
        "ruby_event_store-active_record",
        services: [postgres_11, postgres_15, mysql_5, mysql_8],
        matrix:
          generate(
            ruby_version(RUBY_VERSIONS),
            bundle_gemfile(GEMFILE, AR_GEMFILES),
            join(
              generate(
                database_url(SQLITE3),
                data_type(DATA_TYPES_IN_AR.take(1))
              ),
              generate(database_url(POSTGRES), data_type(DATA_TYPES_IN_AR)),
              generate(database_url(MYSQL), data_type(DATA_TYPES_IN_AR.take(2)))
            )
          )
      ),
      release_mutate("ruby_event_store-active_record"),
      release_coverage("ruby_event_store-active_record"),
      contrib_test(
        "ruby_event_store-flipper",
        matrix:
          generate(ruby_version(MRI_RUBY), bundle_gemfile(GEMFILE, AS_GEMFILES))
      ),
      contrib_mutate("ruby_event_store-flipper"),
      contrib_coverage("ruby_event_store-flipper"),
      contrib_test("ruby_event_store-newrelic"),
      contrib_mutate("ruby_event_store-newrelic"),
      contrib_coverage("ruby_event_store-newrelic"),
      contrib_test(
        "ruby_event_store-outbox",
        services: [mysql_5, mysql_8],
        matrix:
          join(
            generate(
              ruby_version(MRI_RUBY),
              bundle_gemfile(GEMFILE, RAILS_GEMFILES, GEMFILE_SIDEKIQ_5_2),
              database_url(SQLITE3, MYSQL_5, MYSQL_8)
            )
          ),
        steps: [
          checkout,
          setup_nix,
          setup_cachix,
          verify_lockfile,
          setup_ruby,
          make_nix_shell("test")
        ]
      ),
      contrib_mutate(
        "ruby_event_store-outbox",
        steps: [
          checkout(depth: 0),
          setup_nix,
          setup_cachix,
          verify_lockfile,
          setup_ruby,
          make_nix_shell("mutate-changes")
        ]
      ),
      contrib_coverage(
        "ruby_event_store-outbox",
        steps: [
          checkout,
          setup_nix,
          setup_cachix,
          verify_lockfile,
          setup_ruby,
          make_nix_shell("mutate")
        ]
      ),
      contrib_test("ruby_event_store-profiler"),
      contrib_mutate("ruby_event_store-profiler"),
      contrib_coverage("ruby_event_store-profiler"),
      contrib_test(
        "ruby_event_store-protobuf",
        matrix:
          join(
            generate(
              ruby_version(MRI_RUBY.drop(1)),
              bundle_gemfile(GEMFILE, RAILS_GEMFILES),
              database_url(SQLITE3)
            )
          )
      ),
      contrib_mutate(
        "ruby_event_store-protobuf",
        matrix:
          generate(
            ruby_version(MRI_RUBY.drop(1).take(1)),
            bundle_gemfile(GEMFILE)
          )
      ),
      contrib_coverage(
        "ruby_event_store-protobuf",
        matrix:
          generate(
            ruby_version(MRI_RUBY.drop(1).take(1)),
            bundle_gemfile(GEMFILE)
          )
      ),
      contrib_test(
        "ruby_event_store-rom",
        services: [postgres_11, postgres_15, mysql_5, mysql_8],
        matrix:
          join(
            generate(
              ruby_version(MRI_RUBY),
              bundle_gemfile(GEMFILE),
              database_url(SQLITE),
              data_type(DATA_TYPES_IN_SEQUEL.take(1))
            ),
            generate(
              ruby_version(MRI_RUBY.take(1)),
              bundle_gemfile(GEMFILE),
              database_url(POSTGRES),
              data_type(DATA_TYPES_IN_SEQUEL)
            ),
            generate(
              ruby_version(MRI_RUBY.take(1)),
              bundle_gemfile(GEMFILE),
              database_url(MYSQL),
              data_type(DATA_TYPES_IN_SEQUEL.take(1))
            )
          )
      ),
      contrib_mutate("ruby_event_store-rom"),
      contrib_coverage("ruby_event_store-rom"),
      contrib_test(
        "ruby_event_store-sequel",
        services: [postgres_11, postgres_15, mysql_5, mysql_8],
        matrix:
          join(
            generate(
              ruby_version(MRI_RUBY),
              bundle_gemfile(GEMFILE),
              database_url(SQLITE),
              data_type(DATA_TYPES_IN_SEQUEL.take(1))
            ),
            generate(
              ruby_version(MRI_RUBY.take(1)),
              bundle_gemfile(GEMFILE),
              database_url(POSTGRES),
              data_type(DATA_TYPES_IN_SEQUEL)
            ),
            generate(
              ruby_version(MRI_RUBY.take(1)),
              bundle_gemfile(GEMFILE),
              database_url(MYSQL),
              data_type(DATA_TYPES_IN_SEQUEL.take(1))
            )
          )
      ),
      contrib_mutate("ruby_event_store-sequel"),
      contrib_coverage("ruby_event_store-sequel"),
      contrib_test(
        "ruby_event_store-sidekiq_scheduler",
        matrix:
          join(
            generate(
              ruby_version(MRI_RUBY),
              bundle_gemfile(GEMFILE, SIDEKIQ_GEMFILES)
            )
          ),
        steps: [
          checkout,
          setup_nix,
          setup_cachix,
          verify_lockfile,
          setup_ruby,
          make_nix_shell("test")
        ]
      ),
      contrib_mutate(
        "ruby_event_store-sidekiq_scheduler",
        steps: [
          checkout(depth: 0),
          setup_nix,
          setup_cachix,
          verify_lockfile,
          setup_ruby,
          make_nix_shell("mutate-changes")
        ]
      ),
      contrib_coverage(
        "ruby_event_store-sidekiq_scheduler",
        steps: [
          checkout,
          setup_nix,
          setup_cachix,
          verify_lockfile,
          setup_ruby,
          make_nix_shell("mutate")
        ]
      ),
      contrib_test("ruby_event_store-transformations"),
      contrib_mutate("ruby_event_store-transformations"),
      contrib_coverage("ruby_event_store-transformations"),
      contrib_test("minitest-ruby_event_store"),
      contrib_mutate("minitest-ruby_event_store"),
      contrib_coverage("minitest-ruby_event_store"),
      contrib_test("dres_client", triggers: dres_triggers("dres_client_test")),
      contrib_test(
        "dres_rails",
        services: [postgres_11, postgres_15],
        matrix:
          join(
            generate(
              ruby_version(MRI_RUBY),
              bundle_gemfile(GEMFILE),
              database_url(POSTGRES),
              data_type(DATA_TYPES_IN_AR)
            )
          ),
        triggers: dres_triggers("dres_rails_test")
      ),
      assets("ruby_event_store-browser")
    ]
  end

  module Actions
    def checkout(depth: 1)
      { "uses" => "actions/checkout@v4", "with" => { "fetch-depth" => depth } }
    end

    def verify_lockfile
      {
        "run" => "test -e ${{ env.BUNDLE_GEMFILE }}.lock",
        "working-directory" => "${{ env.WORKING_DIRECTORY }}"
      }
    end

    def setup_ruby
      {
        "uses" => "ruby/setup-ruby@v1",
        "with" => {
          "ruby-version" => "${{ env.RUBY_VERSION }}",
          "bundler-cache" => true,
          "working-directory" => "${{ env.WORKING_DIRECTORY }}"
        }
      }
    end

    def setup_node
      {
        "uses" => "actions/setup-node@v4",
        "with" => {
          "node-version" => 20,
          "cache" => "npm",
          "cache-dependency-path" =>
            "${{ env.WORKING_DIRECTORY }}/elm/package-lock.json"
        }
      }
    end

    def cache_elm
      {
        "uses" => "actions/cache@v4",
        "with" => {
          "path" => "~/.elm",
          "key" =>
            "elm-${{ hashFiles(format('{0}/elm/elm.json', env.WORKING_DIRECTORY)) }}"
        }
      }
    end

    def setup_nix
      {
        "uses" => "cachix/install-nix-action@v25",
        "with" => {
          "nix_path" => "nixpkgs=channel:nixos-unstable"
        }
      }
    end

    def setup_cachix
      {
        "uses" => "cachix/cachix-action@v14",
        "with" => {
          "name" => "railseventstore",
          "authToken" => "${{ secrets.CACHIX_AUTH_TOKEN }}"
        }
      }
    end

    def make_nix_shell(target, imports: ["redis.nix"])
      {
        "run" => <<~SHELL,
        nix-shell --run "make #{target}" -E"
          with import <nixpkgs> { };
          mkShell {
            inputsFrom = [
              #{imports.map { |i| "(import ../../support/nix/#{i})" }.join("\n")}
            ];
          }
        "
      SHELL
        "working-directory" => "${{ env.WORKING_DIRECTORY }}"
      }
    end

    def make(target)
      body = {
        "run" => "make #{target}",
        "working-directory" => "${{ env.WORKING_DIRECTORY }}",
        "env" => {
          "RUBYOPT" => "--enable-frozen-string-literal"
        }
      }
      body
    end

    def upload_artifact(name)
      {
        "uses" => "actions/upload-artifact@v4",
        "with" => {
          "name" => name,
          "path" => "${{ env.WORKING_DIRECTORY }}/public/#{name}"
        }
      }
    end

    def configure_aws_credentials
      {
        "uses" => "aws-actions/configure-aws-credentials@v4",
        "with" => {
          "aws-access-key-id" => "${{ secrets.AWS_ACCESS_KEY_ID }}",
          "aws-secret-access-key" => "${{ secrets.AWS_SECRET_ACCESS_KEY }}",
          "aws-region" => "eu-central-1"
        }
      }
    end

    def set_short_sha_env
      {
        "run" =>
          "echo \"SHORT_SHA=$(git rev-parse --short=12 HEAD)\" >> $GITHUB_ENV"
      }
    end

    def aws_s3_sync
      {
        "run" =>
          "aws s3 sync ${{ env.WORKING_DIRECTORY }}/public s3://ruby-event-store-assets/${{ env.SHORT_SHA }}"
      }
    end
  end
  include Actions

  module Triggers
    def manual_trigger
      { "workflow_dispatch" => nil }
    end

    def api_trigger
      { "repository_dispatch" => { "types" => ["script"] } }
    end

    def push_trigger(paths = [])
      return { "push" => nil } if paths.empty?
      { "push" => { "paths" => paths } }
    end

    def pr_trigger(paths)
      { "pull_request" => { "types" => %w[opened reopened], "paths" => paths } }
    end

    def scheduled_trigger
      { "schedule" => [{ "cron" => "0 17 * * *" }] }
    end

    def release_triggers(workflow_name)
      paths = release_paths(workflow_name)
      [
        manual_trigger,
        api_trigger,
        push_trigger(paths.dup),
        pr_trigger(paths.dup)
      ]
    end

    def contrib_triggers(workflow_name, working_directory)
      paths = contrib_paths(workflow_name, working_directory)
      [
        manual_trigger,
        api_trigger,
        push_trigger(paths.dup),
        pr_trigger(paths.dup)
      ]
    end

    def coverage_triggers(workflow_name, working_directory)
      paths = coverage_paths(workflow_name, working_directory)
      [
        manual_trigger,
        api_trigger,
        push_trigger(paths.dup),
        pr_trigger(paths.dup),
        scheduled_trigger
      ]
    end

    def dres_triggers(workflow_name)
      paths = dres_paths(workflow_name)
      [
        manual_trigger,
        api_trigger,
        push_trigger(paths.dup),
        pr_trigger(paths.dup)
      ]
    end

    def release_paths(workflow_name)
      [
        %w[
          aggregate_root
          rails_event_store
          ruby_event_store
          ruby_event_store-active_record
          ruby_event_store-browser
          ruby_event_store-rspec
        ].map { |name| "#{name}/**" },
        workflow_paths(workflow_name),
        support_paths
      ].reduce(&:concat).uniq
    end

    def contrib_paths(workflow_name, working_directory)
      [
        own_paths(working_directory),
        workflow_paths(workflow_name),
        support_paths
      ].reduce(&:concat).uniq
    end

    def coverage_paths(workflow_name, working_directory)
      [
        [working_directory].map { |wd| "#{wd}/Gemfile.lock" },
        workflow_paths(workflow_name),
        support_paths
      ].reduce(&:concat).uniq
    end

    def dres_paths(workflow_name)
      [
        %w[dres_client dres_rails].map { |name| "contrib/#{name}/**" },
        workflow_paths(workflow_name),
        support_paths
      ].reduce(&:concat).uniq
    end

    def own_paths(working_directory)
      %W[#{working_directory}/**]
    end

    def workflow_paths(workflow_name)
      %W[.github/workflows/#{workflow_name}.yml]
    end

    def support_paths
      %w[support/** !support/bundler/** !support/ci/**]
    end
  end
  include Triggers

  module Services
    def postgres_11
      {
        "postgres_11" => {
          "image" => "postgres:11",
          "env" => {
            "POSTGRES_DB" => "rails_event_store",
            "POSTGRES_PASSWORD" => "secret"
          },
          "ports" => ["10011:5432"],
          "options" =>
            "--health-cmd \"pg_isready\" --health-interval 10s --health-timeout 5s --health-retries 5"
        }
      }
    end

    def postgres_15
      {
        "postgres_15" => {
          "image" => "postgres:15",
          "env" => {
            "POSTGRES_DB" => "rails_event_store",
            "POSTGRES_PASSWORD" => "secret"
          },
          "ports" => ["10015:5432"],
          "options" =>
            "--health-cmd \"pg_isready\" --health-interval 10s --health-timeout 5s --health-retries 5"
        }
      }
    end

    def mysql_8
      {
        "mysql_8" => {
          "image" => "mysql:8",
          "env" => {
            "MYSQL_DATABASE" => "rails_event_store",
            "MYSQL_ROOT_PASSWORD" => "secret"
          },
          "ports" => ["10008:3306"],
          "options" =>
            "--health-cmd \"mysqladmin ping\" --health-interval 10s --health-timeout 5s --health-retries 5"
        }
      }
    end

    def mysql_5
      {
        "mysql_5" => {
          "image" => "mysql:5",
          "env" => {
            "MYSQL_DATABASE" => "rails_event_store",
            "MYSQL_ROOT_PASSWORD" => "secret"
          },
          "ports" => ["10005:3306"],
          "options" =>
            "--health-cmd \"mysqladmin ping\" --health-interval 10s --health-timeout 5s --health-retries 5"
        }
      }
    end
  end
  include Services

  module Matrix
    def generate(*axes)
      Szczupac.generate(*axes)
    end

    def axis(name, *items)
      Szczupac.axis(name, Array(items.flatten))
    end

    def join(*axes)
      axes.flatten.uniq
    end

    def ruby_version(*ruby_version)
      axis("ruby_version", *ruby_version)
    end

    def bundle_gemfile(*gemfile)
      axis("bundle_gemfile", *gemfile)
    end

    def database_url(*database_url)
      axis("database_url", *database_url)
    end

    def data_type(*data_type)
      axis("data_type", *data_type)
    end
  end
  include Matrix

  module Workflows
    class Workflow
      include Triggers
      include Matrix
      include Actions

      def initialize(
        gem,
        job_name: "test",
        name: "#{gem}_#{job_name}",
        working_directory: gem,
        matrix: generate(ruby_version(RUBY_VERSIONS), bundle_gemfile(GEMFILE)),
        steps: [checkout, verify_lockfile, setup_ruby, make("test")],
        services: [],
        triggers: release_triggers(name)
      )
        @gem = gem
        @job_name = job_name
        @name = name
        @working_directory = working_directory
        @matrix = matrix
        @steps = steps
        @services = services
        @triggers = triggers
      end

      def to_h
        {
          "name" => name,
          "on" => triggers.reduce(&:merge),
          "jobs" => {
            job_name => job
          }
        }
      end

      attr_reader :gem,
                  :job_name,
                  :name,
                  :working_directory,
                  :matrix,
                  :steps,
                  :services,
                  :triggers

      private

      def job
        {
          "runs-on" => "ubuntu-20.04",
          "timeout-minutes" => 120,
          "env" => { "WORKING_DIRECTORY" => working_directory }.merge(
            env(matrix)
          ),
          "services" => services.reduce(&:merge),
          "strategy" => {
            "fail-fast" => false,
            "matrix" => {
              "include" => matrix
            }
          },
          "steps" => steps
        }.reject { |k, _| k == "services" && services.empty? }
          .reject { |k, _| k == "strategy" && matrix.empty? }
      end

      def env(matrix)
        matrix
          .take(1)
          .reduce({}) do |acc, matrix_item|
            matrix_item.reduce(acc) do |acc, (key, _)|
              acc.merge(key.upcase => "${{ matrix.#{key} }}")
            end
          end
      end
    end

    def release_test(name, **)
      Workflow.new(name, **)
    end

    def contrib_test(
      name,
      working_directory: "contrib/#{name}",
      matrix: generate(ruby_version(MRI_RUBY), bundle_gemfile(GEMFILE)),
      **
    )
      Workflow.new(
        name,
        working_directory: working_directory,
        matrix: matrix,
        triggers: contrib_triggers("#{name}_test", working_directory),
        **
      )
    end

    def release_mutate(
      name,
      matrix: generate(ruby_version(MRI_RUBY.take(1)), bundle_gemfile(GEMFILE)),
      steps: [
        checkout(depth: 0),
        verify_lockfile,
        setup_ruby,
        make("mutate-changes")
      ],
      **
    )
      Workflow.new(name, job_name: "mutate", matrix: matrix, steps: steps, **)
    end

    def contrib_mutate(
      name,
      working_directory: "contrib/#{name}",
      matrix: generate(ruby_version(MRI_RUBY.take(1)), bundle_gemfile(GEMFILE)),
      steps: [
        checkout(depth: 0),
        verify_lockfile,
        setup_ruby,
        make("mutate-changes")
      ],
      **
    )
      Workflow.new(
        name,
        job_name: "mutate",
        working_directory: working_directory,
        matrix: matrix,
        steps: steps,
        triggers: contrib_triggers("#{name}_mutate", working_directory),
        **
      )
    end

    def release_coverage(
      name,
      matrix: generate(ruby_version(MRI_RUBY.take(1)), bundle_gemfile(GEMFILE)),
      steps: [checkout, verify_lockfile, setup_ruby, make("mutate")],
      **
    )
      Workflow.new(
        name,
        job_name: "coverage",
        matrix: matrix,
        steps: steps,
        triggers: coverage_triggers("#{name}_coverage", name),
        **
      )
    end

    def contrib_coverage(
      name,
      working_directory: "contrib/#{name}",
      matrix: generate(ruby_version(MRI_RUBY.take(1)), bundle_gemfile(GEMFILE)),
      steps: [checkout, verify_lockfile, setup_ruby, make("mutate")],
      **
    )
      Workflow.new(
        name,
        job_name: "coverage",
        working_directory: working_directory,
        matrix: matrix,
        steps: steps,
        triggers: coverage_triggers("#{name}_coverage", working_directory),
        **
      )
    end

    def assets(name)
      Workflow.new(
        name,
        job_name: "assets",
        matrix: [],
        steps: [
          checkout,
          setup_node,
          cache_elm,
          make("install-npm"),
          make("build-npm"),
          upload_artifact("ruby_event_store_browser.js"),
          upload_artifact("ruby_event_store_browser.css"),
          configure_aws_credentials,
          set_short_sha_env,
          aws_s3_sync
        ],
        triggers: [manual_trigger, api_trigger, push_trigger]
      )
    end
  end
  include Workflows

  def as_github_actions
    workflows.each do |workflow|
      filename = "#{workflow.name}.yml"
      File.write(File.join(workflows_root, filename), as_yaml(workflow.to_h))
      puts "writing #{filename}"
    end
  end

  def as_yaml(content)
    Psych
      .safe_dump(content, line_width: 120)
      .lines
      .drop(1)
      .join
      .strip
      .gsub(/'on':\n/, "on:\n")
  end

  def initialize(workflows_root, template_root)
    @workflows_root = workflows_root
    @template_root = template_root
  end

  attr_reader :workflows_root, :template_root
end

CI.new(
  File.join(__dir__, "../../.github/workflows/"),
  __dir__
).as_github_actions