nvoi 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,816 @@
1
+ # NVOI Rails Dashboard
2
+
3
+ A Rails app that serves as a dashboard for nvoi deploys running in CI.
4
+
5
+ ---
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ ┌─────────────────────────────────────────────────────────────────┐
11
+ │ User's Repo │
12
+ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
13
+ │ │ deploy.enc │ │ deploy.key │ │ .github/workflows/ │ │
14
+ │ │ (committed) │ │ (GH secret) │ │ deploy.yml │ │
15
+ │ └──────────────┘ └──────────────┘ └────────────────────────┘ │
16
+ └─────────────────────────────────────────────────────────────────┘
17
+
18
+ │ git push
19
+
20
+ ┌─────────────────────────────────────────────────────────────────┐
21
+ │ CI (GitHub Actions, etc.) │
22
+ │ │
23
+ │ gem install nvoi │
24
+ │ echo "$DEPLOY_KEY" > deploy.key │
25
+ │ nvoi deploy [--branch $BRANCH] │
26
+ │ │
27
+ │ ┌────────────────────────────────────────────────────────────┐ │
28
+ │ │ nvoi gem: │ │
29
+ │ │ 1. Decrypts deploy.enc with deploy.key │ │
30
+ │ │ 2. Provisions infra / deploys app │ │
31
+ │ │ 3. POSTs logs to callback_url (signed with deploy.key) │ │
32
+ │ └────────────────────────────────────────────────────────────┘ │
33
+ └─────────────────────────────────────────────────────────────────┘
34
+
35
+ │ HTTPS callbacks
36
+
37
+ ┌─────────────────────────────────────────────────────────────────┐
38
+ │ Rails Dashboard │
39
+ │ │
40
+ │ • Receives deploy logs via API │
41
+ │ • Stores deploy history │
42
+ │ • Real-time log streaming via Turbo │
43
+ │ • Config wizard (generates deploy.enc + CI files) │
44
+ └─────────────────────────────────────────────────────────────────┘
45
+ ```
46
+
47
+ **Key insight:** CI runs the deploy, Rails just watches.
48
+
49
+ ---
50
+
51
+ ## 1. App Setup
52
+
53
+ ```bash
54
+ rails new nvoi-dashboard \
55
+ --database=postgresql \
56
+ --css=tailwind \
57
+ --skip-jbuilder \
58
+ --skip-action-mailbox \
59
+ --skip-action-text
60
+
61
+ cd nvoi-dashboard
62
+ bundle add nvoi
63
+ bundle add omniauth-github
64
+ ```
65
+
66
+ ---
67
+
68
+ ## 2. Database Schema
69
+
70
+ Simplified - no infra state tracking (CI handles that).
71
+
72
+ ```ruby
73
+ # db/migrate/001_create_users.rb
74
+ class CreateUsers < ActiveRecord::Migration[8.0]
75
+ def change
76
+ create_table :users do |t|
77
+ t.bigint :github_id, null: false, index: { unique: true }
78
+ t.string :github_username, null: false
79
+ t.text :github_access_token # encrypted
80
+ t.text :avatar_url
81
+ t.timestamps
82
+ end
83
+ end
84
+ end
85
+
86
+ # db/migrate/002_create_projects.rb
87
+ class CreateProjects < ActiveRecord::Migration[8.0]
88
+ def change
89
+ create_table :projects do |t|
90
+ t.references :user, null: false, foreign_key: { on_delete: :cascade }
91
+ t.string :name, null: false
92
+ t.string :github_repo # optional: user/repo
93
+ t.binary :encrypted_config
94
+ t.binary :encrypted_key
95
+ t.string :callback_token, null: false, index: { unique: true }
96
+ t.timestamps
97
+ end
98
+ end
99
+ end
100
+
101
+ # db/migrate/003_create_deploys.rb
102
+ class CreateDeploys < ActiveRecord::Migration[8.0]
103
+ def change
104
+ create_table :deploys do |t|
105
+ t.references :project, null: false, foreign_key: { on_delete: :cascade }
106
+ t.string :external_id, null: false # CI run ID
107
+ t.string :branch
108
+ t.string :git_sha, limit: 40
109
+ t.string :git_ref
110
+ t.string :status, default: "running"
111
+ t.string :ci_provider
112
+ t.text :ci_run_url
113
+ t.text :error_message
114
+ t.jsonb :result_data # tunnels, duration, etc.
115
+ t.timestamp :started_at
116
+ t.timestamp :finished_at
117
+ t.timestamps
118
+ t.index [:project_id, :external_id], unique: true
119
+ t.index [:project_id, :created_at]
120
+ end
121
+ end
122
+ end
123
+
124
+ # db/migrate/004_create_deploy_logs.rb
125
+ class CreateDeployLogs < ActiveRecord::Migration[8.0]
126
+ def change
127
+ create_table :deploy_logs do |t|
128
+ t.references :deploy, null: false, foreign_key: { on_delete: :cascade }
129
+ t.string :level, null: false
130
+ t.text :message, null: false
131
+ t.timestamp :logged_at, null: false
132
+ t.index [:deploy_id, :logged_at]
133
+ end
134
+ end
135
+ end
136
+ ```
137
+
138
+ ---
139
+
140
+ ## 3. Models
141
+
142
+ ```ruby
143
+ # app/models/user.rb
144
+ class User < ApplicationRecord
145
+ encrypts :github_access_token
146
+
147
+ has_many :projects, dependent: :destroy
148
+ end
149
+
150
+ # app/models/project.rb
151
+ class Project < ApplicationRecord
152
+ belongs_to :user
153
+ has_many :deploys, dependent: :destroy
154
+
155
+ encrypts :encrypted_config
156
+ encrypts :encrypted_key
157
+
158
+ before_create :generate_callback_token
159
+
160
+ def callback_url
161
+ Rails.application.routes.url_helpers.api_project_deploys_url(
162
+ callback_token,
163
+ host: Rails.application.config.app_host
164
+ )
165
+ end
166
+
167
+ def deploy_key
168
+ encrypted_key
169
+ end
170
+
171
+ def decrypted_config
172
+ return {} unless encrypted_config && encrypted_key
173
+ YAML.safe_load(
174
+ Nvoi::Utils::Crypto.decrypt(encrypted_config, encrypted_key)
175
+ )
176
+ end
177
+
178
+ def save_config(data)
179
+ key = encrypted_key || SecureRandom.hex(32)
180
+ self.encrypted_key = key
181
+ self.encrypted_config = Nvoi::Utils::Crypto.encrypt(data.to_yaml, key)
182
+ save!
183
+ end
184
+
185
+ private
186
+
187
+ def generate_callback_token
188
+ self.callback_token ||= SecureRandom.urlsafe_base64(32)
189
+ end
190
+ end
191
+
192
+ # app/models/deploy.rb
193
+ class Deploy < ApplicationRecord
194
+ belongs_to :project
195
+ has_many :logs, class_name: "DeployLog", dependent: :delete_all
196
+
197
+ enum :status, {
198
+ running: "running",
199
+ completed: "completed",
200
+ failed: "failed"
201
+ }
202
+
203
+ scope :recent, -> { order(created_at: :desc).limit(50) }
204
+
205
+ def duration
206
+ return nil unless started_at && finished_at
207
+ finished_at - started_at
208
+ end
209
+
210
+ def tunnels
211
+ result_data&.dig("tunnels") || []
212
+ end
213
+ end
214
+
215
+ # app/models/deploy_log.rb
216
+ class DeployLog < ApplicationRecord
217
+ belongs_to :deploy
218
+
219
+ after_create_commit :broadcast
220
+
221
+ private
222
+
223
+ def broadcast
224
+ Turbo::StreamsChannel.broadcast_append_to(
225
+ deploy,
226
+ target: "deploy_logs",
227
+ partial: "deploys/log_line",
228
+ locals: { log: self }
229
+ )
230
+ end
231
+ end
232
+ ```
233
+
234
+ ---
235
+
236
+ ## 4. API Controller (receives callbacks from gem)
237
+
238
+ ```ruby
239
+ # app/controllers/api/deploys_controller.rb
240
+ module Api
241
+ class DeploysController < ApplicationController
242
+ skip_before_action :verify_authenticity_token
243
+ skip_before_action :authenticate_user!
244
+ before_action :set_project
245
+ before_action :verify_signature
246
+
247
+ # POST /api/projects/:callback_token/deploys/:external_id/logs
248
+ def logs
249
+ deploy = find_or_create_deploy
250
+
251
+ params[:logs].each do |log_data|
252
+ deploy.logs.create!(
253
+ level: log_data[:level],
254
+ message: log_data[:message],
255
+ logged_at: log_data[:logged_at]
256
+ )
257
+ end
258
+
259
+ head :ok
260
+ end
261
+
262
+ # POST /api/projects/:callback_token/deploys/:external_id/status
263
+ def status
264
+ deploy = find_or_create_deploy
265
+
266
+ case params[:status]
267
+ when "started"
268
+ deploy.update!(
269
+ status: :running,
270
+ started_at: Time.current,
271
+ git_sha: params[:git_sha],
272
+ git_ref: params[:git_ref],
273
+ branch: params[:git_ref],
274
+ ci_provider: params[:ci_provider],
275
+ ci_run_url: params[:ci_run_url]
276
+ )
277
+ when "completed"
278
+ deploy.update!(
279
+ status: :completed,
280
+ finished_at: Time.current,
281
+ result_data: {
282
+ tunnels: params[:tunnels],
283
+ duration_seconds: params[:duration_seconds]
284
+ }
285
+ )
286
+ broadcast_status(deploy)
287
+ when "failed"
288
+ deploy.update!(
289
+ status: :failed,
290
+ finished_at: Time.current,
291
+ error_message: params[:error]
292
+ )
293
+ broadcast_status(deploy)
294
+ end
295
+
296
+ head :ok
297
+ end
298
+
299
+ private
300
+
301
+ def set_project
302
+ @project = Project.find_by!(callback_token: params[:callback_token])
303
+ end
304
+
305
+ def verify_signature
306
+ signature = request.headers["X-Nvoi-Signature"]
307
+ expected = "sha256=" + OpenSSL::HMAC.hexdigest(
308
+ "SHA256",
309
+ @project.deploy_key,
310
+ request.raw_post
311
+ )
312
+
313
+ head :unauthorized unless Rack::Utils.secure_compare(signature, expected)
314
+ end
315
+
316
+ def find_or_create_deploy
317
+ @project.deploys.find_or_create_by!(external_id: params[:external_id])
318
+ end
319
+
320
+ def broadcast_status(deploy)
321
+ Turbo::StreamsChannel.broadcast_replace_to(
322
+ deploy,
323
+ target: "deploy_status",
324
+ partial: "deploys/status",
325
+ locals: { deploy: deploy }
326
+ )
327
+ end
328
+ end
329
+ end
330
+ ```
331
+
332
+ ---
333
+
334
+ ## 5. Dashboard Controllers
335
+
336
+ ```ruby
337
+ # app/controllers/application_controller.rb
338
+ class ApplicationController < ActionController::Base
339
+ before_action :authenticate_user!
340
+
341
+ helper_method :current_user
342
+
343
+ private
344
+
345
+ def current_user
346
+ @current_user ||= User.find_by(id: session[:user_id])
347
+ end
348
+
349
+ def authenticate_user!
350
+ redirect_to login_path unless current_user
351
+ end
352
+ end
353
+
354
+ # app/controllers/projects_controller.rb
355
+ class ProjectsController < ApplicationController
356
+ before_action :set_project, only: [:show, :edit, :update, :destroy, :export]
357
+
358
+ def index
359
+ @projects = current_user.projects.order(updated_at: :desc)
360
+ end
361
+
362
+ def show
363
+ @deploys = @project.deploys.recent
364
+ end
365
+
366
+ def new
367
+ @project = current_user.projects.build
368
+ end
369
+
370
+ def create
371
+ @project = current_user.projects.build(project_params)
372
+
373
+ if @project.save
374
+ redirect_to project_path(@project), notice: "Project created"
375
+ else
376
+ render :new, status: :unprocessable_entity
377
+ end
378
+ end
379
+
380
+ def edit
381
+ @config = @project.decrypted_config
382
+ end
383
+
384
+ def update
385
+ @project.save_config(config_params.to_h)
386
+ redirect_to project_path(@project), notice: "Config saved"
387
+ rescue => e
388
+ flash.now[:alert] = e.message
389
+ render :edit, status: :unprocessable_entity
390
+ end
391
+
392
+ # Export deploy.enc, deploy.key, and CI workflow files
393
+ def export
394
+ send_data generate_export_zip,
395
+ filename: "#{@project.name}-nvoi-config.zip",
396
+ type: "application/zip"
397
+ end
398
+
399
+ private
400
+
401
+ def set_project
402
+ @project = current_user.projects.find(params[:id])
403
+ end
404
+
405
+ def project_params
406
+ params.require(:project).permit(:name, :github_repo)
407
+ end
408
+
409
+ def config_params
410
+ params.require(:config).permit!
411
+ end
412
+
413
+ def generate_export_zip
414
+ require "zip"
415
+
416
+ Zip::OutputStream.write_buffer do |zip|
417
+ # deploy.enc
418
+ zip.put_next_entry("deploy.enc")
419
+ zip.write(@project.encrypted_config)
420
+
421
+ # deploy.key (user adds to CI secrets)
422
+ zip.put_next_entry("deploy.key.txt")
423
+ zip.write("Add this as DEPLOY_KEY secret in your CI:\n\n")
424
+ zip.write(@project.deploy_key)
425
+
426
+ # GitHub Actions workflows
427
+ zip.put_next_entry(".github/workflows/deploy-prod.yml")
428
+ zip.write(generate_prod_workflow)
429
+
430
+ zip.put_next_entry(".github/workflows/deploy-branch.yml")
431
+ zip.write(generate_branch_workflow)
432
+
433
+ # README
434
+ zip.put_next_entry("NVOI_SETUP.md")
435
+ zip.write(generate_readme)
436
+ end.string
437
+ end
438
+
439
+ def generate_prod_workflow
440
+ <<~YAML
441
+ name: Deploy Production
442
+
443
+ on:
444
+ push:
445
+ branches: [main]
446
+
447
+ jobs:
448
+ deploy:
449
+ runs-on: ubuntu-latest
450
+ steps:
451
+ - uses: actions/checkout@v4
452
+
453
+ - uses: ruby/setup-ruby@v1
454
+ with:
455
+ ruby-version: '3.3'
456
+
457
+ - run: gem install nvoi
458
+
459
+ - run: echo "${{ secrets.DEPLOY_KEY }}" > deploy.key
460
+
461
+ - run: nvoi deploy
462
+ YAML
463
+ end
464
+
465
+ def generate_branch_workflow
466
+ <<~YAML
467
+ name: Deploy Branch
468
+
469
+ on:
470
+ push:
471
+ branches-ignore: [main]
472
+
473
+ jobs:
474
+ deploy:
475
+ runs-on: ubuntu-latest
476
+ steps:
477
+ - uses: actions/checkout@v4
478
+
479
+ - uses: ruby/setup-ruby@v1
480
+ with:
481
+ ruby-version: '3.3'
482
+
483
+ - run: gem install nvoi
484
+
485
+ - run: echo "${{ secrets.DEPLOY_KEY }}" > deploy.key
486
+
487
+ - run: nvoi deploy --branch ${{ github.ref_name }}
488
+ YAML
489
+ end
490
+
491
+ def generate_readme
492
+ <<~MD
493
+ # NVOI Setup
494
+
495
+ ## Files in this export
496
+
497
+ - `deploy.enc` - Encrypted deploy configuration (commit this)
498
+ - `deploy.key.txt` - Encryption key (add as CI secret, DO NOT commit)
499
+ - `.github/workflows/deploy-prod.yml` - Deploy on push to main
500
+ - `.github/workflows/deploy-branch.yml` - Deploy branches with prefix
501
+
502
+ ## Setup Steps
503
+
504
+ 1. Copy `deploy.enc` to your repo root
505
+ 2. Copy `.github/workflows/*.yml` to your repo
506
+ 3. Add `DEPLOY_KEY` secret in GitHub repo settings:
507
+ - Go to Settings → Secrets and variables → Actions
508
+ - Click "New repository secret"
509
+ - Name: `DEPLOY_KEY`
510
+ - Value: contents of `deploy.key.txt`
511
+ 4. Push to main to trigger first deploy
512
+
513
+ ## How it works
514
+
515
+ - Push to `main` → deploys to production
516
+ - Push to any other branch → deploys with branch prefix (isolated infra)
517
+ - Deploy logs stream to: #{@project.callback_url}
518
+ MD
519
+ end
520
+ end
521
+
522
+ # app/controllers/deploys_controller.rb
523
+ class DeploysController < ApplicationController
524
+ before_action :set_project
525
+ before_action :set_deploy, only: [:show]
526
+
527
+ def index
528
+ @deploys = @project.deploys.recent
529
+ end
530
+
531
+ def show
532
+ @logs = @deploy.logs.order(:logged_at)
533
+ end
534
+
535
+ private
536
+
537
+ def set_project
538
+ @project = current_user.projects.find(params[:project_id])
539
+ end
540
+
541
+ def set_deploy
542
+ @deploy = @project.deploys.find(params[:id])
543
+ end
544
+ end
545
+ ```
546
+
547
+ ---
548
+
549
+ ## 6. Routes
550
+
551
+ ```ruby
552
+ # config/routes.rb
553
+ Rails.application.routes.draw do
554
+ # Auth
555
+ get "/auth/github/callback", to: "sessions#create"
556
+ get "/login", to: "sessions#new"
557
+ delete "/logout", to: "sessions#destroy"
558
+
559
+ # API - receives callbacks from nvoi gem running in CI
560
+ namespace :api do
561
+ scope "/projects/:callback_token/deploys/:external_id" do
562
+ post "logs", to: "deploys#logs"
563
+ post "status", to: "deploys#status"
564
+ end
565
+ end
566
+
567
+ # Dashboard
568
+ resources :projects do
569
+ member do
570
+ get :export
571
+ end
572
+
573
+ resources :deploys, only: [:index, :show]
574
+ end
575
+
576
+ root "projects#index"
577
+ end
578
+ ```
579
+
580
+ ---
581
+
582
+ ## 7. Views
583
+
584
+ ```erb
585
+ <%# app/views/projects/show.html.erb %>
586
+ <div class="project">
587
+ <header class="flex justify-between items-center">
588
+ <h1><%= @project.name %></h1>
589
+ <div class="actions">
590
+ <%= link_to "Edit Config", edit_project_path(@project), class: "btn" %>
591
+ <%= link_to "Export", export_project_path(@project), class: "btn btn-primary" %>
592
+ </div>
593
+ </header>
594
+
595
+ <section class="callback-info">
596
+ <h3>Callback URL</h3>
597
+ <code><%= @project.callback_url %></code>
598
+ <p class="text-sm text-gray-500">
599
+ This is automatically included in your deploy.enc
600
+ </p>
601
+ </section>
602
+
603
+ <section class="deploys">
604
+ <h2>Recent Deploys</h2>
605
+
606
+ <% if @deploys.any? %>
607
+ <table>
608
+ <thead>
609
+ <tr>
610
+ <th>Branch</th>
611
+ <th>Commit</th>
612
+ <th>Status</th>
613
+ <th>Duration</th>
614
+ <th>Started</th>
615
+ </tr>
616
+ </thead>
617
+ <tbody>
618
+ <% @deploys.each do |deploy| %>
619
+ <tr>
620
+ <td><%= deploy.branch %></td>
621
+ <td>
622
+ <code><%= deploy.git_sha&.first(7) %></code>
623
+ </td>
624
+ <td>
625
+ <%= render "deploys/status_badge", deploy: deploy %>
626
+ </td>
627
+ <td>
628
+ <% if deploy.duration %>
629
+ <%= pluralize(deploy.duration.round, "second") %>
630
+ <% end %>
631
+ </td>
632
+ <td>
633
+ <%= link_to time_ago_in_words(deploy.created_at) + " ago",
634
+ project_deploy_path(@project, deploy) %>
635
+ </td>
636
+ </tr>
637
+ <% end %>
638
+ </tbody>
639
+ </table>
640
+ <% else %>
641
+ <p class="empty">No deploys yet. Push to your repo to trigger a deploy.</p>
642
+ <% end %>
643
+ </section>
644
+ </div>
645
+
646
+ <%# app/views/deploys/show.html.erb %>
647
+ <div class="deploy">
648
+ <header>
649
+ <h1>Deploy <%= @deploy.git_sha&.first(7) %></h1>
650
+ <div id="deploy_status">
651
+ <%= render "deploys/status", deploy: @deploy %>
652
+ </div>
653
+ </header>
654
+
655
+ <dl class="deploy-meta">
656
+ <dt>Branch</dt>
657
+ <dd><%= @deploy.branch %></dd>
658
+
659
+ <dt>Commit</dt>
660
+ <dd><code><%= @deploy.git_sha %></code></dd>
661
+
662
+ <% if @deploy.ci_run_url %>
663
+ <dt>CI Run</dt>
664
+ <dd><%= link_to "View in #{@deploy.ci_provider}", @deploy.ci_run_url, target: "_blank" %></dd>
665
+ <% end %>
666
+
667
+ <% if @deploy.duration %>
668
+ <dt>Duration</dt>
669
+ <dd><%= pluralize(@deploy.duration.round, "second") %></dd>
670
+ <% end %>
671
+
672
+ <% @deploy.tunnels.each do |tunnel| %>
673
+ <dt><%= tunnel["service_name"] %></dt>
674
+ <dd><%= link_to tunnel["hostname"], "https://#{tunnel['hostname']}", target: "_blank" %></dd>
675
+ <% end %>
676
+ </dl>
677
+
678
+ <%= turbo_stream_from @deploy %>
679
+
680
+ <div id="deploy_logs" class="logs">
681
+ <% @logs.each do |log| %>
682
+ <%= render "deploys/log_line", log: log %>
683
+ <% end %>
684
+ </div>
685
+ </div>
686
+
687
+ <%# app/views/deploys/_log_line.html.erb %>
688
+ <div class="log-line log-<%= log.level %>">
689
+ <span class="timestamp"><%= log.logged_at.strftime("%H:%M:%S.%L") %></span>
690
+ <span class="level"><%= log.level %></span>
691
+ <span class="message"><%= log.message %></span>
692
+ </div>
693
+
694
+ <%# app/views/deploys/_status.html.erb %>
695
+ <% case deploy.status.to_sym %>
696
+ <% when :running %>
697
+ <span class="badge badge-blue animate-pulse">Deploying...</span>
698
+ <% when :completed %>
699
+ <span class="badge badge-green">Completed</span>
700
+ <% when :failed %>
701
+ <span class="badge badge-red">Failed</span>
702
+ <% if deploy.error_message %>
703
+ <p class="error-message"><%= deploy.error_message %></p>
704
+ <% end %>
705
+ <% end %>
706
+
707
+ <%# app/views/deploys/_status_badge.html.erb %>
708
+ <% case deploy.status.to_sym %>
709
+ <% when :running %>
710
+ <span class="badge badge-blue">●</span>
711
+ <% when :completed %>
712
+ <span class="badge badge-green">✓</span>
713
+ <% when :failed %>
714
+ <span class="badge badge-red">✗</span>
715
+ <% end %>
716
+ ```
717
+
718
+ ---
719
+
720
+ ## 8. Config Editor (Optional Wizard)
721
+
722
+ ```ruby
723
+ # app/controllers/configs_controller.rb
724
+ class ConfigsController < ApplicationController
725
+ before_action :set_project
726
+
727
+ def edit
728
+ @config = @project.decrypted_config
729
+ @step = params[:step]&.to_i || 1
730
+ end
731
+
732
+ def update
733
+ # Merge step data into config
734
+ current_config = @project.decrypted_config
735
+ updated_config = current_config.deep_merge(config_params.to_h)
736
+
737
+ @project.save_config(updated_config)
738
+
739
+ if params[:next_step]
740
+ redirect_to edit_project_config_path(@project, step: params[:next_step])
741
+ else
742
+ redirect_to project_path(@project), notice: "Config saved"
743
+ end
744
+ end
745
+
746
+ private
747
+
748
+ def set_project
749
+ @project = current_user.projects.find(params[:project_id])
750
+ end
751
+
752
+ def config_params
753
+ params.require(:config).permit!
754
+ end
755
+ end
756
+ ```
757
+
758
+ ---
759
+
760
+ ## Summary
761
+
762
+ This Rails app:
763
+
764
+ 1. **Does NOT run deploys** - CI does that
765
+ 2. **Receives callbacks** from nvoi gem running in CI
766
+ 3. **Stores deploy history** and logs
767
+ 4. **Streams logs in real-time** via Turbo
768
+ 5. **Config wizard** generates deploy.enc
769
+ 6. **Exports** deploy.enc + deploy.key + CI workflow files
770
+ 7. **One secret** (DEPLOY_KEY) encrypts config AND signs callbacks
771
+
772
+ ### User Flow
773
+
774
+ 1. Create project in dashboard
775
+ 2. Configure via wizard (or paste YAML)
776
+ 3. Click "Export" → download zip
777
+ 4. Commit `deploy.enc` to repo
778
+ 5. Add `DEPLOY_KEY` to CI secrets
779
+ 6. Push to trigger deploy
780
+ 7. Watch logs stream in dashboard
781
+
782
+ ### CI Files Generated
783
+
784
+ ```yaml
785
+ # .github/workflows/deploy-prod.yml
786
+ on:
787
+ push:
788
+ branches: [main]
789
+ jobs:
790
+ deploy:
791
+ runs-on: ubuntu-latest
792
+ steps:
793
+ - uses: actions/checkout@v4
794
+ - uses: ruby/setup-ruby@v1
795
+ with: { ruby-version: '3.3' }
796
+ - run: gem install nvoi
797
+ - run: echo "${{ secrets.DEPLOY_KEY }}" > deploy.key
798
+ - run: nvoi deploy
799
+
800
+ # .github/workflows/deploy-branch.yml
801
+ on:
802
+ push:
803
+ branches-ignore: [main]
804
+ jobs:
805
+ deploy:
806
+ runs-on: ubuntu-latest
807
+ steps:
808
+ - uses: actions/checkout@v4
809
+ - uses: ruby/setup-ruby@v1
810
+ with: { ruby-version: '3.3' }
811
+ - run: gem install nvoi
812
+ - run: echo "${{ secrets.DEPLOY_KEY }}" > deploy.key
813
+ - run: nvoi deploy --branch ${{ github.ref_name }}
814
+ ```
815
+
816
+ Works with any CI that can run Ruby and has secrets support.