Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,34 @@ jobs:
- name: jscpd (Duplicate Code Detection)
run: pnpm exec jscpd .

audit:
name: Security Audit
# The Release workflow waits for this workflow, so a release can't ship a dependency with a known advisory
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup Ruby
uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
ruby-version: '.ruby-version'
bundler-cache: true

- name: Setup Node.js
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22

- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10.18.3

- name: Check locked Ruby and docs dependencies for known advisories
run: scripts/security_audit.sh

docs:
name: 'Next.js Site Tests'
runs-on: ubuntu-22.04
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ For template file changes (tests, rake tasks, etc.), just run `scripts/rails_tes
- Run all linting: `task lint`
- Auto-fix all lint issues: `task lint:fix`
- Spellcheck: `task spellcheck`
- Check locked dependencies for known advisories: `task security` (also a CI job, so releases wait for it)

#### Ruby Commands

Expand Down
6 changes: 4 additions & 2 deletions Taskfile.docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ tasks:
typecheck:
dir: docs
desc: Run TypeScript type checking
deps: [deps]
# The pages import docs/generated, which isn't committed
deps: [deps, ':ruby:generate']
cmds:
- pnpm exec tsc --noEmit

Expand All @@ -32,6 +33,7 @@ tasks:
test:
dir: docs
desc: Run frontend tests
deps: [deps]
# The tests import docs/generated, which isn't committed
deps: [deps, ':ruby:generate']
cmds:
- npm test
3 changes: 1 addition & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ tasks:
security:
desc: Check locked Ruby and docs dependencies for known advisories
cmds:
- bundle exec bundler-audit check --update
- cd docs && pnpm audit
- scripts/security_audit.sh

fix:
desc: Auto-fix lint issues then run the full validation workflow (replaces scripts/all_write.sh)
Expand Down
24 changes: 0 additions & 24 deletions scripts/generate_sorbet_log_structs.rb

This file was deleted.

54 changes: 39 additions & 15 deletions scripts/generate_structs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
require "sorbet-runtime"
require "fileutils"
require "tempfile"
require "tmpdir"

$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "log_struct/enums"
require_relative "../tools/codegen/staging"
require_relative "../tools/codegen/sorbet_generator"
require_relative "../tools/codegen/ts_generator"
require_relative "../tools/codegen/log_fields_generator"
Expand All @@ -23,42 +25,64 @@ module Scripts
sig { void }
def self.run
root = T.let(File.expand_path("..", __dir__), String)
# In the repository, so each generated file moves into place with a rename
staging_parent = File.join(root, "tmp")
FileUtils.mkdir_p(staging_parent)
Dir.mktmpdir("codegen-", staging_parent) do |dir|
generate(root, LogStruct::Codegen::Staging.new(root, dir))
end
end

sig { params(root: String, staging: LogStruct::Codegen::Staging).void }
def self.generate(root, staging)
puts "Generating Sorbet Ruby structs..."
LogStruct::Codegen::SorbetGenerator.generate_all(root)
ruby_files = LogStruct::Codegen::SorbetGenerator.generate_all(root, staging)

puts "Formatting generated Ruby code..."
Tempfile.create("logstruct-codegen-rubocop") do |output|
unless system("bundle", "exec", "rubocop", "-A", "lib/log_struct/log", out: output, err: [:child, :out])
output.rewind
warn(output.read)
raise "Failed to format generated Ruby log structs"
end
end
format_ruby(root, staging, ruby_files)
# The JSON export loads these structs
staging.install(LogStruct::Codegen::SorbetGenerator::OUT_DIR)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

puts "Generating log fields JSON..."
LogStruct::Codegen::LogFieldsGenerator.generate(root)
LogStruct::Codegen::LogFieldsGenerator.generate(staging)
staging.install(File.dirname(LogStruct::Codegen::LogFieldsGenerator::OUT_FILE))

puts "Generating TypeScript artifacts..."
LogStruct::Codegen::TSGenerator.generate_all(root)
LogStruct::Codegen::TSGenerator.generate_all(root, staging)

puts "Exporting Sorbet JSON..."
require "sorbet/typescript"

# Ensure the freshly generated Sorbet structs are loaded before exporting
require "log_struct/log"

output_dir = File.join(root, "docs", "generated", "logstruct")
output_dir = LogStruct::Codegen::TSGenerator::OUT_DIR

exporter = Sorbet::Typescript::Exporter.new(
enum_namespaces: ["LogStruct"],
struct_namespaces: ["LogStruct::Log"]
)

exporter.export(
enums_json: File.join(output_dir, "sorbet-enums.json"),
structs_json: File.join(output_dir, "sorbet-log-structs.json")
enums_json: staging.path(File.join(output_dir, "sorbet-enums.json")),
structs_json: staging.path(File.join(output_dir, "sorbet-log-structs.json"))
)
staging.install(output_dir, prune: true)
end

# RuboCop's config names files by their paths relative to it (plain.rb may have an untyped prop), so a copy at
# the staging root applies to each staged file as it does to the file it replaces
sig { params(root: String, staging: LogStruct::Codegen::Staging, files: T::Array[String]).void }
def self.format_ruby(root, staging, files)
FileUtils.cp(File.join(root, ".rubocop.yml"), staging.path(".rubocop.yml"))
Tempfile.create("logstruct-codegen-rubocop") do |output|
# The files are named, because RuboCop leaves out what's under tmp/ when it searches a directory. Sorbet
# can't check a splat of an Array whose size it doesn't know.
command = ["bundle", "exec", "rubocop", "-A", *files]
unless system(*T.unsafe(command), chdir: staging.dir, out: output, err: [:child, :out])
output.rewind
warn(output.read)
raise "Failed to format generated Ruby log structs"
end
end
end
end

Expand Down
8 changes: 8 additions & 0 deletions scripts/security_audit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")/.."

# Known advisories in the locked Ruby and docs dependencies
bundle exec bundler-audit check --update
cd docs && pnpm audit
70 changes: 70 additions & 0 deletions test/workflows/codegen_staging_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# typed: true
# frozen_string_literal: true

require "test_helper"
require "tmpdir"
require_relative "../../tools/codegen/staging"

module LogStruct
class CodegenStagingTest < ActiveSupport::TestCase
setup do
@root = Dir.mktmpdir
@staging = Codegen::Staging.new(@root, File.join(@root, "tmp", "codegen"))
end

teardown do
FileUtils.rm_rf(@root)
end

test "staged files stay out of the repository until they're installed" do
write_file("lib/log/plain.rb", "committed")

@staging.write("lib/log/plain.rb", "generated")

assert_equal "committed", File.read(File.join(@root, "lib/log/plain.rb"))
end

test "install replaces the files it staged and keeps the others" do
write_file("lib/log/plain.rb", "committed")
write_file("lib/log/interfaces/common_fields.rb", "written by hand")
@staging.write("lib/log/plain.rb", "generated")
@staging.write("lib/log/puma/start.rb", "new source")

@staging.install("lib/log")

assert_equal "generated", File.read(File.join(@root, "lib/log/plain.rb"))
assert_equal "new source", File.read(File.join(@root, "lib/log/puma/start.rb"))
assert_equal "written by hand", File.read(File.join(@root, "lib/log/interfaces/common_fields.rb"))
assert_empty Dir.glob("**/*.rb", base: @staging.path("lib/log"))
end

test "install with prune removes files and directories that are no longer generated" do
write_file("docs/generated/index.ts", "old")
write_file("docs/generated/sources/removed/index.ts", "old")
write_file("docs/other.ts", "outside")
@staging.write("docs/generated/index.ts", "new")

@staging.install("docs/generated", prune: true)

assert_equal ["index.ts"], Dir.children(File.join(@root, "docs/generated"))
assert_equal "new", File.read(File.join(@root, "docs/generated/index.ts"))
assert_equal "outside", File.read(File.join(@root, "docs/other.ts"))
end

test "install with nothing staged leaves the repository alone" do
write_file("schemas/meta/log-fields.json", "committed")

@staging.install("schemas/meta", prune: true)

assert_equal "committed", File.read(File.join(@root, "schemas/meta/log-fields.json"))
end

private

def write_file(relative_path, content)
path = File.join(@root, relative_path)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, content)
end
end
end
52 changes: 41 additions & 11 deletions test/workflows/generation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,42 @@
# frozen_string_literal: true

require "test_helper"
require "digest"
require "open3"
require "tmpdir"

module LogStruct
class GenerationTest < ActiveSupport::TestCase
# Skip writes in the child process so failure checks cannot change generated files.
SETUP = <<~RUBY
ROOT = File.expand_path("../..", __dir__)

LOAD_GENERATORS = <<~RUBY
require "bundler/setup"
require "sorbet-runtime"
$LOAD_PATH.unshift(File.expand_path("lib"))
require "log_struct/enums"
require_relative "tools/codegen/sorbet_generator"
require_relative "tools/codegen/ts_generator"
require_relative "tools/codegen/log_fields_generator"
LogStruct::Codegen::SorbetGenerator.define_singleton_method(:generate_all) { |root| }
LogStruct::Codegen::TSGenerator.define_singleton_method(:generate_all) { |root| }
LogStruct::Codegen::LogFieldsGenerator.define_singleton_method(:generate) { |root| }
RUBY

# Skip writes in the child process so failure checks cannot change generated files.
STUB_GENERATORS = <<~RUBY
LogStruct::Codegen::SorbetGenerator.define_singleton_method(:generate_all) { |root, staging| ["lib/log_struct/log/stub.rb"] }
LogStruct::Codegen::TSGenerator.define_singleton_method(:generate_all) { |root, staging| }
LogStruct::Codegen::LogFieldsGenerator.define_singleton_method(:generate) { |staging| }
RUBY

test "a failed formatter stops generation" do
output, status = run_generator("Kernel.define_method(:system) { |*args| false }")
output, status = run_generator(STUB_GENERATORS + "Kernel.define_method(:system) { |*args, **opts| false }")

refute_predicate status, :success?, output
assert_includes output, "Failed to format generated Ruby log structs"
assert_not_includes output, "Exporting Sorbet JSON"
end

test "a failed JSON export fails generation" do
output, status = run_generator(<<~RUBY)
Kernel.define_method(:system) { |*args| true }
output, status = run_generator(STUB_GENERATORS + <<~RUBY)
Kernel.define_method(:system) { |*args, **opts| true }
require "sorbet/typescript"
Sorbet::Typescript::Exporter.define_method(:export) { |**args| raise "intentional export failure" }
RUBY
Expand All @@ -39,11 +46,34 @@ class GenerationTest < ActiveSupport::TestCase
assert_includes output, "intentional export failure"
end

# `task ci` interrupts the generator when a step running beside it fails
test "an interrupted formatter leaves the generated files as they were" do
Dir.mktmpdir do |copy|
%w[lib schemas scripts tools .rubocop.yml].each { |path| FileUtils.cp_r(File.join(ROOT, path), copy) }
before = file_digests(copy)

output, status = run_generator("Kernel.define_method(:system) { |*args, **opts| raise Interrupt }", chdir: copy)

refute_predicate status, :success?, output
assert_includes output, "Formatting generated Ruby code..."
assert_includes output, "Interrupt"
assert_equal before, file_digests(copy)
assert_empty Dir.children(File.join(copy, "tmp"))
end
end

private

def run_generator(injection)
script = SETUP + injection + "\nload 'scripts/generate_structs.rb'\n"
Open3.capture2e(RbConfig.ruby, "-e", script, chdir: File.expand_path("../..", __dir__))
def run_generator(injection, chdir: ROOT)
script = LOAD_GENERATORS + injection + "\nload 'scripts/generate_structs.rb'\n"
env = {"BUNDLE_GEMFILE" => File.join(ROOT, "Gemfile")}
Open3.capture2e(env, RbConfig.ruby, "-e", script, chdir: chdir)
end

def file_digests(dir)
Dir.glob("{lib,schemas}/**/*", base: dir).select { |path| File.file?(File.join(dir, path)) }.to_h do |path|
[path, Digest::SHA256.file(File.join(dir, path)).hexdigest]
end
end
end
end
11 changes: 6 additions & 5 deletions tools/codegen/log_fields_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@
rescue LoadError
require_relative "../../lib/log_struct/enums/log_field"
end
require_relative "staging"

module LogStruct
module Codegen
module LogFieldsGenerator
extend T::Sig

sig { params(root: String).void }
def self.generate(root)
out_file = File.join(root, "schemas", "meta", "log-fields.json")
OUT_FILE = "schemas/meta/log-fields.json"

sig { params(staging: Staging).void }
def self.generate(staging)
names = enum_constant_names
data = {
"$schema" => "http://json-schema.org/draft-07/schema#",
Expand All @@ -32,8 +33,8 @@ def self.generate(root)
}
}

File.write(out_file, JSON.pretty_generate(data) + "\n")
puts "Generated: #{out_file} (#{names.size} fields)"
staging.write(OUT_FILE, JSON.pretty_generate(data) + "\n")
puts "Generated: #{OUT_FILE} (#{names.size} fields)"
end

sig { returns(T::Array[String]) }
Expand Down
Loading