Wire Solid Queue and add jwt/webmock gems

Solid Queue was declared (Gemfile, production queue: database) but never installed: no bin/jobs, no queue.yml, no queue schema, adapter still commented out. Run the installer and point production at the queue database, ahead of the first real ActiveJob (GitHub PR reviews). jwt mints the RS256 App JWTs for GitHub App installation tokens; webmock stubs GitHub REST calls in specs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WhPzinYJUYgBed63XMvJX1

Claude committed Jul 2, 2026 at 12:21 UTC 78c7a21dfc3c5a333234533bcf317e5757cdb004
7 files changed +198 -1
Gemfile
+6
@@ -31,6 +31,9 @@ gem "redcarpet", "~> 3.6"
31 # Syntax highlighting for code files
32 gem "rouge", "~> 4.5"
33
34 +# RS256 JWT minting for GitHub App authentication (installation access tokens)
35 +gem "jwt", "~> 3.1"
36 +
37 # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
38 # gem "bcrypt", "~> 3.1.7"
39
@@ -77,6 +80,9 @@ group :development, :test do
80
81 # RSpec for request/model specs [https://github.com/rspec/rspec-rails]
82 gem "rspec-rails", "~> 8.0"
83 +
84 + # Stub outbound HTTP (GitHub REST) in specs
85 + gem "webmock", require: false
86 end
87
88 group :development do
Gemfile.lock
+20
@@ -94,6 +94,9 @@ GEM
94 logger (~> 1.5)
95 concurrent-ruby (1.3.7)
96 connection_pool (3.0.2)
97 + crack (1.0.1)
98 + bigdecimal
99 + rexml
100 crass (1.0.6)
101 date (3.5.1)
102 debug (1.11.1)
@@ -111,11 +114,13 @@ GEM
114 et-orbi (1.4.0)
115 tzinfo
116 ffi (1.17.4-arm64-darwin)
117 + ffi (1.17.4-x86_64-linux-gnu)
118 fugit (1.12.2)
119 et-orbi (~> 1.4)
120 raabro (~> 1.4)
121 globalid (1.3.0)
122 activesupport (>= 6.1)
123 + hashdiff (1.2.1)
124 i18n (1.15.1)
125 concurrent-ruby (~> 1.0)
126 image_processing (1.14.0)
@@ -135,6 +140,8 @@ GEM
140 actionview (>= 7.0.0)
141 activesupport (>= 7.0.0)
142 json (2.19.9)
143 + jwt (3.2.0)
144 + base64
145 kamal (2.12.0)
146 activesupport (>= 7.0)
147 base64 (~> 0.2)
@@ -189,12 +196,15 @@ GEM
196 nio4r (2.7.5)
197 nokogiri (1.19.4-arm64-darwin)
198 racc (~> 1.4)
199 + nokogiri (1.19.4-x86_64-linux-gnu)
200 + racc (~> 1.4)
201 ostruct (0.6.3)
202 parallel (2.1.0)
203 parser (3.3.11.1)
204 ast (~> 2.4.1)
205 racc
206 pg (1.6.3-arm64-darwin)
207 + pg (1.6.3-x86_64-linux)
208 pp (0.6.3)
209 prettyprint
210 prettyprint (0.2.0)
@@ -262,6 +272,7 @@ GEM
272 regexp_parser (2.12.0)
273 reline (0.6.3)
274 io-console (~> 0.5)
275 + rexml (3.4.4)
276 rouge (4.7.0)
277 rspec-core (3.13.6)
278 rspec-support (~> 3.13.0)
@@ -347,8 +358,10 @@ GEM
358 railties (>= 7.0.0)
359 tailwindcss-ruby (~> 3.0)
360 tailwindcss-ruby (3.4.19-arm64-darwin)
361 + tailwindcss-ruby (3.4.19-x86_64-linux)
362 thor (1.5.0)
363 thruster (0.1.21-arm64-darwin)
364 + thruster (0.1.21-x86_64-linux)
365 timeout (0.6.1)
366 tsort (0.2.0)
367 turbo-rails (2.0.23)
@@ -365,6 +378,10 @@ GEM
378 actionview (>= 8.0.0)
379 bindex (>= 0.4.0)
380 railties (>= 8.0.0)
381 + webmock (3.26.2)
382 + addressable (>= 2.8.0)
383 + crack (>= 0.3.2)
384 + hashdiff (>= 0.4.0, < 2.0.0)
385 websocket-driver (0.8.1)
386 base64
387 websocket-extensions (>= 0.1.0)
@@ -373,6 +390,7 @@ GEM
390
391 PLATFORMS
392 arm64-darwin
393 + x86_64-linux
394
395 DEPENDENCIES
396 bootsnap
@@ -383,6 +401,7 @@ DEPENDENCIES
401 image_processing (~> 1.2)
402 importmap-rails
403 jbuilder
404 + jwt (~> 3.1)
405 kamal
406 letter_opener
407 pg (~> 1.1)
@@ -404,6 +423,7 @@ DEPENDENCIES
423 turbo-rails
424 tzinfo-data
425 web-console
426 + webmock
427
428 BUNDLED WITH
429 2.7.2
bin/jobs new
+6
@@ -0,0 +1,6 @@
1 +#!/usr/bin/env ruby
2 +
3 +require_relative "../config/environment"
4 +require "solid_queue/cli"
5 +
6 +SolidQueue::Cli.start(ARGV)
config/environments/production.rb
+2 -1
@@ -50,7 +50,8 @@ Rails.application.configure do
50 # config.cache_store = :mem_cache_store
51
52 # Replace the default in-process and non-durable queuing backend for Active Job.
53 - # config.active_job.queue_adapter = :resque
53 + config.active_job.queue_adapter = :solid_queue
54 + config.solid_queue.connects_to = { database: { writing: :queue } }
55
56 # Ignore bad email addresses and do not raise email delivery errors.
57 # Set this to true and configure the email server for immediate delivery to raise delivery errors.
config/queue.yml new
+20
@@ -0,0 +1,20 @@
1 +default: &default
2 + dispatchers:
3 + - polling_interval: 1
4 + batch_size: 500
5 + workers:
6 + - queues: "*"
7 + # Each review job can pin a thread on an Onde Cloud request for up to
8 + # ~90s (OndeCloudService::READ_TIMEOUT) — keep concurrency low.
9 + threads: 2
10 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
11 + polling_interval: 0.1
12 +
13 +development:
14 + <<: *default
15 +
16 +test:
17 + <<: *default
18 +
19 +production:
20 + <<: *default
config/recurring.yml new
+15
@@ -0,0 +1,15 @@
1 +# examples:
2 +# periodic_cleanup:
3 +# class: CleanSoftDeletedRecordsJob
4 +# queue: background
5 +# args: [ 1000, { batch_size: 500 } ]
6 +# schedule: every hour
7 +# periodic_cleanup_with_command:
8 +# command: "SoftDeletedRecord.due.delete_all"
9 +# priority: 2
10 +# schedule: at 5am every day
11 +
12 +production:
13 + clear_solid_queue_finished_jobs:
14 + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
15 + schedule: every hour at minute 12
db/queue_schema.rb new
+129
@@ -0,0 +1,129 @@
1 +ActiveRecord::Schema[7.1].define(version: 1) do
2 + create_table "solid_queue_blocked_executions", force: :cascade do |t|
3 + t.bigint "job_id", null: false
4 + t.string "queue_name", null: false
5 + t.integer "priority", default: 0, null: false
6 + t.string "concurrency_key", null: false
7 + t.datetime "expires_at", null: false
8 + t.datetime "created_at", null: false
9 + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
10 + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
11 + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
12 + end
13 +
14 + create_table "solid_queue_claimed_executions", force: :cascade do |t|
15 + t.bigint "job_id", null: false
16 + t.bigint "process_id"
17 + t.datetime "created_at", null: false
18 + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
19 + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
20 + end
21 +
22 + create_table "solid_queue_failed_executions", force: :cascade do |t|
23 + t.bigint "job_id", null: false
24 + t.text "error"
25 + t.datetime "created_at", null: false
26 + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
27 + end
28 +
29 + create_table "solid_queue_jobs", force: :cascade do |t|
30 + t.string "queue_name", null: false
31 + t.string "class_name", null: false
32 + t.text "arguments"
33 + t.integer "priority", default: 0, null: false
34 + t.string "active_job_id"
35 + t.datetime "scheduled_at"
36 + t.datetime "finished_at"
37 + t.string "concurrency_key"
38 + t.datetime "created_at", null: false
39 + t.datetime "updated_at", null: false
40 + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
41 + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
42 + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
43 + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
44 + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
45 + end
46 +
47 + create_table "solid_queue_pauses", force: :cascade do |t|
48 + t.string "queue_name", null: false
49 + t.datetime "created_at", null: false
50 + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
51 + end
52 +
53 + create_table "solid_queue_processes", force: :cascade do |t|
54 + t.string "kind", null: false
55 + t.datetime "last_heartbeat_at", null: false
56 + t.bigint "supervisor_id"
57 + t.integer "pid", null: false
58 + t.string "hostname"
59 + t.text "metadata"
60 + t.datetime "created_at", null: false
61 + t.string "name", null: false
62 + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
63 + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
64 + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
65 + end
66 +
67 + create_table "solid_queue_ready_executions", force: :cascade do |t|
68 + t.bigint "job_id", null: false
69 + t.string "queue_name", null: false
70 + t.integer "priority", default: 0, null: false
71 + t.datetime "created_at", null: false
72 + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
73 + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
74 + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
75 + end
76 +
77 + create_table "solid_queue_recurring_executions", force: :cascade do |t|
78 + t.bigint "job_id", null: false
79 + t.string "task_key", null: false
80 + t.datetime "run_at", null: false
81 + t.datetime "created_at", null: false
82 + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
83 + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
84 + end
85 +
86 + create_table "solid_queue_recurring_tasks", force: :cascade do |t|
87 + t.string "key", null: false
88 + t.string "schedule", null: false
89 + t.string "command", limit: 2048
90 + t.string "class_name"
91 + t.text "arguments"
92 + t.string "queue_name"
93 + t.integer "priority", default: 0
94 + t.boolean "static", default: true, null: false
95 + t.text "description"
96 + t.datetime "created_at", null: false
97 + t.datetime "updated_at", null: false
98 + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
99 + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
100 + end
101 +
102 + create_table "solid_queue_scheduled_executions", force: :cascade do |t|
103 + t.bigint "job_id", null: false
104 + t.string "queue_name", null: false
105 + t.integer "priority", default: 0, null: false
106 + t.datetime "scheduled_at", null: false
107 + t.datetime "created_at", null: false
108 + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
109 + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
110 + end
111 +
112 + create_table "solid_queue_semaphores", force: :cascade do |t|
113 + t.string "key", null: false
114 + t.integer "value", default: 1, null: false
115 + t.datetime "expires_at", null: false
116 + t.datetime "created_at", null: false
117 + t.datetime "updated_at", null: false
118 + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
119 + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
120 + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
121 + end
122 +
123 + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
124 + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
125 + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
126 + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
127 + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
128 + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
129 +end