-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathRakefile
More file actions
347 lines (305 loc) · 14 KB
/
Copy pathRakefile
File metadata and controls
347 lines (305 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# frozen_string_literal: true
require "English"
require "rspec/core/rake_task"
# Unit + request specs (the fast suite the release task runs). System specs need
# a browser and run in CI; invoke them explicitly with `rake spec:system`.
RSpec::Core::RakeTask.new(:spec) do |t|
t.pattern = "spec/{phlex,requests}/**/*_spec.rb"
end
namespace :spec do
desc "Run browser system specs (needs Playwright). CAPYBARA_SERVER=puma|falcon (default puma)"
RSpec::Core::RakeTask.new(:system) do |t|
t.pattern = "spec/system/**/*_spec.rb"
end
desc "Run the browser system specs under BOTH real servers (puma + falcon)"
task :system_servers do
# A reactive round trip must be transport-agnostic — prove it under both the
# sync (Puma) and async (Falcon) server before a client-touching change ships.
%w[puma falcon].each do |server|
puts "\e[1;35m\n### system specs under CAPYBARA_SERVER=#{server} ###\e[0m"
sh({ "CAPYBARA_SERVER" => server }, "bundle exec rspec spec/system")
end
end
desc "Run the browser system specs across server × transport (puma/falcon × cable/pgbus)"
task :system_matrix do
# Issue #187: the full transport matrix, mirroring :system_servers one
# dimension over. The pgbus cells need Postgres + the pgbus schema (rake
# pgbus:prepare_test_db); they're SKIPPED with a clear note when Postgres
# isn't reachable locally, so the cable cells still prove the round trip.
servers = %w[puma falcon]
transports = %w[cable pgbus]
postgres = system("pg_isready", %i[out err] => File::NULL)
warn "\e[1;33m\n### Postgres not reachable — skipping pgbus cells (cable only) ###\e[0m" unless postgres
servers.each do |server|
transports.each do |transport|
pgbus = transport == "pgbus"
next puts("\e[1;33m### SKIP #{server}/pgbus (no Postgres) ###\e[0m") if pgbus && !postgres
sh({ "TRANSPORT" => "pgbus" }, "bundle exec rake pgbus:prepare_test_db") if pgbus
puts "\e[1;35m\n### system specs — CAPYBARA_SERVER=#{server} TRANSPORT=#{transport} ###\e[0m"
sh({ "CAPYBARA_SERVER" => server, "TRANSPORT" => transport }, "bundle exec rspec spec/system")
end
end
end
end
namespace :pgbus do
desc "Prepare the Postgres test DB for the pgbus transport cell (schema + PGMQ) — issue #187"
task :prepare_test_db do
# The setup lives in spec/support/prepare_pgbus_db.rb (boots the dummy under
# TRANSPORT=pgbus, loads the app schema, installs pgbus's vendored PGMQ — no
# CREATE EXTENSION, so a plain postgres:18 works). Used by CI and :system_matrix.
sh({ "TRANSPORT" => "pgbus", "RAILS_ENV" => "test" },
"bundle exec ruby spec/support/prepare_pgbus_db.rb")
end
end
require "rubocop/rake_task"
RuboCop::RakeTask.new
# --- Performance benchmarks -------------------------------------------------
# Micro-benches isolate the hot methods (render, reactive_token, param
# coercion); the request bench drives the dummy app through derailed_benchmarks
# for end-to-end latency + memory. See docs/performance.md.
namespace :bench do
micro_dir = "benchmark/micro"
desc "Run the micro-benchmarks (render, token, coerce_params)"
task :micro do
files = Dir["#{micro_dir}/*.rb"]
abort "No micro-benchmarks found in #{micro_dir}" if files.empty?
# Capture a plain-text report (CI uploads it as an artifact) while still
# streaming to the console. Strip ANSI colors from the saved copy.
require "fileutils"
FileUtils.mkdir_p("tmp/benchmarks")
failed = []
File.open("tmp/benchmarks/micro.txt", "w") do |out|
files.each do |file|
header = "\n### #{file} ###"
puts "\e[1;35m#{header}\e[0m"
out.puts header
result = `ruby #{file} 2>&1`
puts result
out.puts result.gsub(/\e\[[0-9;]*m/, "")
# A crashed bench must not pass silently — record the failure (and note it
# in the saved report) so CI surfaces a broken bench instead of a green tick.
unless $CHILD_STATUS.success?
failed << file
out.puts "!!! FAILED (exit #{$CHILD_STATUS.exitstatus})"
end
end
end
puts "\nSaved report to tmp/benchmarks/micro.txt"
abort "\nBenchmark(s) failed: #{failed.join(", ")}" if failed.any?
end
desc "Run a single micro-benchmark: rake bench:one[render]"
task :one, [:name] do |_t, args|
name = args[:name] or abort "Usage: rake bench:one[render|token|coerce_params]"
# Resolve against the actual bench files (no shell interpolation of arbitrary
# input into an executable path) so a stray name can't escape the benchmark dir.
available = Dir["#{micro_dir}/*.rb"].map { |f| File.basename(f, ".rb") }
abort "No such benchmark: #{name}. Available: #{available.sort.join(", ")}" unless available.include?(name)
ruby "#{micro_dir}/#{name}.rb"
end
desc "End-to-end request-cycle benchmark (derailed; needs a booted dummy app)"
task :request do
require "fileutils"
FileUtils.mkdir_p("tmp/benchmarks")
sh({ "RAILS_ENV" => "test" }, "ruby benchmark/request/derailed.rb")
end
desc "Run the client dispatch micro-benchmarks (extractToken, collectFields, recompute) via bun"
task :client do
# The client hot path (reactive_controller.js) is benched off-browser with
# mitata + happy-dom, driven through the controller's PUBLIC surface only —
# so NOTHING under app/javascript/ is touched (no __bench exports). See
# benchmark/client/ and docs/…/performance.rb for the honest framing (the
# happy-dom numbers are engine-relative; the extractToken regex numbers are
# engine-faithful under bun/JSC).
require "fileutils"
FileUtils.mkdir_p("tmp/benchmarks")
header = "\n### benchmark/client/index.bench.js ###"
puts "\e[1;35m#{header}\e[0m"
result = `bun run benchmark/client/index.bench.js 2>&1`
puts result
File.open("tmp/benchmarks/client.txt", "w") do |out|
out.puts header
out.puts result.gsub(/\e\[[0-9;]*m/, "")
# A crashed bench must not pass silently — index.bench.js runs mitata with
# `throw: true`, so a throwing bench exits non-zero. Record the failure in
# the saved report and abort, matching the bench:micro contract.
out.puts "!!! FAILED (exit #{$CHILD_STATUS.exitstatus})" unless $CHILD_STATUS.success?
end
puts "\nSaved report to tmp/benchmarks/client.txt"
abort "\nClient benchmark failed (exit #{$CHILD_STATUS.exitstatus})" unless $CHILD_STATUS.success?
end
end
desc "Run the micro-benchmark suite (alias for bench:micro)"
task bench: "bench:micro"
# --- Client build -----------------------------------------------------------
# The browser ships a MINIFIED twin of each authored client module (the source
# stays comment-dense — it's the documentation and what the JS suite imports).
# Output is deterministic, so the .min.js/.min.js.map are committed and shipped
# in the gem; consumers need no bun. See scripts/build_client.js.
namespace :build do
min_glob = "app/javascript/phlex/reactive/*.min.js*"
desc "Minify the client runtime (reactive_controller/confirm/compute) via bun"
task :js do
sh "bun run scripts/build_client.js"
end
desc "Verify the committed minified client matches a fresh build (CI drift guard)"
task js_check: :js do
# A deterministic build means the rebuild leaves the tracked artifacts
# byte-identical to what's checked in. `git diff` compares the working tree
# against the index/HEAD, so a fresh checkout that rebuilds cleanly passes;
# a source edit without a rebuild-and-commit shows a diff and fails CI.
# The pathspec is QUOTED so git expands it against the index — not the shell
# against the working tree: an unquoted glob only sees files still on disk, so
# deleting a module (and its committed .min.js/.map) would slip past the guard.
sh "git diff --exit-code -- '#{min_glob}'" do |ok, _res|
unless ok
warn "\e[31mMinified client is stale — run `rake build:js` and commit the result.\e[0m"
abort "Committed .min.js/.min.js.map do not match a fresh build."
end
end
end
end
desc "Build gem and verify contents"
task :build do
sh("gem build phlex-reactive.gemspec --strict")
gem_file = Dir["phlex-reactive-*.gem"].first
abort "Gem file not found after build" unless gem_file
sh("gem unpack #{gem_file} --target /tmp/gem-verify")
puts "\n=== Gem contents ==="
sh("find /tmp/gem-verify -type f | sort")
sh("rm -rf /tmp/gem-verify #{gem_file}")
end
desc "Release a new version (rake release[1.2.3] or rake release[pre] or rake release[1.2.3,force])"
task :release, %i[version force] do |_t, args|
require_relative "lib/phlex/reactive/version"
def info(msg) = puts "\e[34m→\e[0m #{msg}"
def success(msg) = puts "\e[32m✓\e[0m #{msg}"
def skip(msg) = puts "\e[33m⊘\e[0m #{msg} \e[33m(skipped)\e[0m"
def header(msg) = puts "\n\e[1;36m#{msg}\e[0m\n#{"─" * msg.length}"
new_version = args[:version]
abort "\e[31mUsage: rake release[X.Y.Z] or rake release[X.Y.Z,force]\e[0m" unless new_version
force = args[:force]&.to_s&.downcase == "force"
dirty = `git status --porcelain`.strip
abort "\e[31mAborting: working directory is not clean.\e[0m\n#{dirty}" unless dirty.empty?
current = Phlex::Reactive::VERSION
prerelease = new_version.match?(/alpha|beta|rc|pre/) || new_version == "pre"
if new_version == "pre"
new_version = current
prerelease = true
end
tag = "v#{new_version}"
version_file = "lib/phlex/reactive/version.rb"
title = "Release #{tag}"
title += " (force)" if force
header title
info "Current version: #{current}"
info "New version: #{new_version}"
info "Pre-release: #{prerelease}"
# Step 0: Force cleanup — delete existing release and tag
if force
header "Force cleanup"
if system("gh release view #{tag} >/dev/null 2>&1")
sh("gh release delete #{tag} --yes --cleanup-tag")
success "Deleted release and remote tag #{tag}"
else
skip "No release #{tag} to delete"
end
if system("git rev-parse #{tag} >/dev/null 2>&1")
sh("git tag -d #{tag}")
success "Deleted local tag #{tag}"
else
skip "No local tag #{tag} to delete"
end
end
# Step 1: Update version file
header "Version"
if new_version == current
skip "Version already #{new_version}"
else
content = File.read(version_file)
content.sub!(/VERSION = ".*"/, "VERSION = \"#{new_version}\"")
File.write(version_file, content)
success "Updated #{version_file}"
end
# Step 1b: Re-lock the docs site's Gemfile.lock. The docs app pins the gem via
# a local `path: ".."`, so its lockfile carries the version string — bumping
# version.rb without re-locking leaves the committed docs lockfile stale (it
# kept the OLD version while every fresh `bundle install` in docs/ regenerated
# it, dirtying the tree on every checkout). `bundle lock --local` re-derives
# only from the path dep — no network, no rubygems fetch, no checksum to
# compute for a path gem — so it works in the release environment and picks up
# the new version. Committed alongside the bump in Step 3. Any OTHER tracked
# lockfile pinning the gem would belong in this list; today only docs does.
docs_lock = "docs/Gemfile.lock"
header "Docs lockfile"
if File.exist?(docs_lock)
# BUNDLE_GEMFILE instead of Dir.chdir — no process-wide cwd change; bundle
# writes docs/Gemfile.lock in place next to the pointed-at Gemfile.
sh({ "BUNDLE_GEMFILE" => "docs/Gemfile" }, "bundle lock --local")
success "Re-locked #{docs_lock} to #{new_version}"
else
skip "No #{docs_lock}"
end
# Step 2: Verify gem builds cleanly
header "Build verification"
sh("gem build phlex-reactive.gemspec --strict")
sh("rm -f phlex-reactive-*.gem")
success "Gem builds cleanly"
# Step 3: Commit the version bump + the re-locked docs lockfile together, so a
# release never leaves a stale/dirty committed lockfile behind. The guard fires
# when EITHER the version file OR the docs lockfile changed (a re-run where only
# the lockfile drifted still commits).
header "Git commit"
release_files = [version_file, (docs_lock if File.exist?(docs_lock))].compact
changed = release_files.any? do |f|
!`git diff #{f}`.strip.empty? || !`git diff --cached #{f}`.strip.empty?
end
if changed
sh("git add #{release_files.join(" ")}")
sh("git commit -m 'chore: bump version to #{new_version}'")
success "Committed version bump + docs lockfile"
else
skip "Nothing to commit (version + docs lockfile already current)"
end
# Step 4: Push to origin
header "Git push"
local_sha = `git rev-parse HEAD`.strip
remote_sha = `git rev-parse origin/main 2>/dev/null`.strip
if local_sha == remote_sha
skip "origin/main already at #{local_sha[0..6]}"
else
sh("git push origin main")
success "Pushed to origin/main"
end
# Step 5: Create release (the Release workflow publishes to RubyGems via OIDC)
header "Release"
tag_exists = system("git rev-parse #{tag} >/dev/null 2>&1")
release_exists = system("gh release view #{tag} >/dev/null 2>&1")
if release_exists
skip "Release #{tag} already exists (use force to re-create)"
elsif tag_exists
info "Tag #{tag} exists, creating release from it"
pre_flag = prerelease ? "--prerelease" : ""
sh("gh release create #{tag} --generate-notes #{pre_flag}".strip)
success "Release #{tag} created from existing tag"
else
pre_flag = prerelease ? "--prerelease" : ""
sh("gh release create #{tag} --generate-notes --target main #{pre_flag}".strip)
success "Release #{tag} created"
end
puts ""
success "\e[1mRelease #{tag} complete!\e[0m CI will handle the rest:"
puts " • Run tests"
puts " • Build + verify gem"
puts " • Sign with Sigstore"
puts " • Publish to RubyGems (trusted publishing)"
puts " • Upload assets to the release"
end
namespace :dummy do
desc "Run the dummy app for local QA (PORT=3010)"
task :server do
port = ENV.fetch("PORT", "3010")
ENV["RAILS_ENV"] = "development"
sh("bundle exec puma spec/dummy/config.ru -p #{port}")
end
end
task default: %i[spec rubocop]