support/ci/generate
#!/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