Add per-PR Fly.io preview deploys
Follows kejadlen/ketchup: a Preview workflow runs superfly/fly-pr-review-apps
on each pull_request, creating a preview app on open, redeploying on push, and
destroying it when the PR closes. fly.preview.toml is the base config, building
the repo Dockerfile with an ephemeral in-image database.

A preview has no proxy in front to write the Remote-User identity header, so it
sets PRO_TACTS_DEFAULT_LOGIN and config.ru inserts the stand-in-login
middleware. That middleware is what dev.ru already used (DevLogin); since it now
serves the preview too, "dev" was the wrong name, so it becomes StubLogin, takes
the login to write as an argument, and keeps its dev default. The proxy stays
the only way into any deployment that serves real data, which leaves
PRO_TACTS_DEFAULT_LOGIN unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rz9eYTeKgfMPoBpCjGkkaW
change
commit 8e4a9f7a24a2fc77850c6b07a3ca990d6690c64c
author Claude <noreply@anthropic.com>
date
parent 417a51bb
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
new file mode 100644
index 0000000..06155e0
--- /dev/null
+++ b/.github/workflows/preview.yml
@@ -0,0 +1,41 @@
+name: Preview
+
+on:
+  pull_request:
+    types: [opened, reopened, synchronize, closed]
+
+permissions: {}
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.number }}
+  cancel-in-progress: true
+
+env:
+  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+  FLY_REGION: sjc
+  FLY_ORG: personal
+
+jobs:
+  preview:
+    name: Preview
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout
+    environment:
+      name: pr-${{ github.event.number }}
+      url: ${{ steps.deploy.outputs.url }}
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+
+      - uses: superfly/flyctl-actions/setup-flyctl@63da3ecc5e2793b98a3f2519b3d75d4f4c11cec2 # master
+
+      # Creates the app on open, redeploys it on each push, and destroys
+      # it when the PR closes.
+      - name: Deploy preview
+        id: deploy
+        uses: superfly/fly-pr-review-apps@f5d85309215d5700e7a42faf9aa5e9a718aaef7c # 1.5.0
+        with:
+          config: fly.preview.toml
+          memory: 256
diff --git a/Rakefile b/Rakefile
index 36bf003..78e9c16 100644
--- a/Rakefile
+++ b/Rakefile
@@ -34,10 +34,10 @@ task :dev do
     data_dir = Pathname.new(dir)
     ENV["PRO_TACTS_DATA_DIR"] = data_dir.to_s
     require_relative "test/fixture_data"
-    require "pro_tacts/dev_login"
+    require "pro_tacts/stub_login"
     store = FixtureData.install(data_dir)
     # Here rather than in FixtureData, which the tests share.
-    store.name_book(ProTacts::DevLogin::LOGIN, "alpha")
+    store.name_book(ProTacts::StubLogin::LOGIN, "alpha")
     store.close
     sh "fd -e rb . lib | entr -r rackup -o localhost dev.ru"
   end
diff --git a/config.ru b/config.ru
index 5d7615c..dee36d8 100644
--- a/config.ru
+++ b/config.ru
@@ -8,6 +8,7 @@ require "sentry-ruby"
 $LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
 require "pro_tacts/web"
 require "pro_tacts/store"
+require "pro_tacts/stub_login"
 
 config = ProTacts.config
 
@@ -52,4 +53,14 @@ end
 
 ProTacts::Web.plugin :common_logger, $stderr
 
+# A deployment with nothing in front to write the identity header (the
+# preview app) names a login in PRO_TACTS_DEFAULT_LOGIN, and StubLogin
+# writes it onto every request that arrives without one, so the app
+# answers rather than refusing everyone (ProxyAuth). Left unset where the
+# proxy is the only way in, which is every deployment that serves real
+# data.
+if (login = config.default_login)
+  use ProTacts::StubLogin, login
+end
+
 run ProTacts::Web.freeze.app
diff --git a/dev.ru b/dev.ru
index 067419e..57674bb 100644
--- a/dev.ru
+++ b/dev.ru
@@ -1,10 +1,11 @@
 # config.ru for `rake dev`, which has no proxy in front of it to write
-# the login (ProTacts::DevLogin). Deployments run config.ru alone.
+# the login (ProTacts::StubLogin). Deployments run config.ru alone; the
+# preview one reaches the same middleware through PRO_TACTS_DEFAULT_LOGIN.
 require "pathname"
 
 $LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
 require "pro_tacts"
-require "pro_tacts/dev_login"
+require "pro_tacts/stub_login"
 
 # The change being worked on names a dev build, the way a release's tag
 # names an image; a change id holds still while its content is edited.
@@ -20,5 +21,5 @@ ProTacts.config = ProTacts::Config.new({
   "VERSION" => change,
 }.merge(ENV))
 
-use ProTacts::DevLogin
+use ProTacts::StubLogin
 run Rack::Builder.parse_file(File.expand_path("config.ru", __dir__))
diff --git a/fly.preview.toml b/fly.preview.toml
new file mode 100644
index 0000000..5abce52
--- /dev/null
+++ b/fly.preview.toml
@@ -0,0 +1,28 @@
+# The per-PR preview app (fly-pr-review-apps overrides the name with the
+# PR's). No volume, so the database is the ephemeral one config.ru builds
+# in the image on boot and every deploy starts empty. Nothing writes the
+# Remote-User header in front of it, so PRO_TACTS_DEFAULT_LOGIN names the
+# login StubLogin stands in with (config.ru); the preview serves that one
+# demo book to anyone who opens the URL.
+app = 'pro-tacts-preview'
+primary_region = 'sjc'
+
+[build]
+
+[http_service]
+  internal_port = 9292
+  force_https = true
+  auto_stop_machines = 'stop'
+  auto_start_machines = true
+  min_machines_running = 0
+  processes = ['app']
+
+[[vm]]
+  memory = '256mb'
+  cpu_kind = 'shared'
+  cpus = 1
+
+[env]
+  PRO_TACTS_INSTANCE_NAME = 'pro-tacts (preview)'
+  PRO_TACTS_ACCENT = 'clay'
+  PRO_TACTS_DEFAULT_LOGIN = 'demo@example.com'
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index 4b46cc5..7703fae 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -34,6 +34,19 @@ module ProTacts
       !value.nil? && value.match?(TRUTHY)
     end
 
+    # A login to answer requests that name nobody, PRO_TACTS_DEFAULT_LOGIN;
+    # nil when unset. A deployment behind a proxy leaves it unset, so the
+    # proxy's Remote-User is the only identity (ProxyAuth). A deployment
+    # with nothing in front — the preview app — names one, and StubLogin
+    # writes it onto every request that arrives without the header, so the
+    # app is reachable without a proxy. Never set where real data is
+    # served: it hands every anonymous caller that login's address book.
+    #: () -> String?
+    def default_login
+      value = @env.fetch("PRO_TACTS_DEFAULT_LOGIN", nil)
+      value unless value.nil? || value.strip.empty?
+    end
+
     # Root data directory: holds the contacts database, and the exported
     # card mirror once there is one. Overridable with
     # PRO_TACTS_DATA_DIR.
diff --git a/lib/pro_tacts/dev_login.rb b/lib/pro_tacts/dev_login.rb
deleted file mode 100644
index 27c0aa0..0000000
--- a/lib/pro_tacts/dev_login.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-require "pro_tacts"
-require "pro_tacts/proxy_auth"
-
-module ProTacts
-  # Rack middleware that writes a fixed login to Remote-User, the way the
-  # proxy in front of a deployment does, so a dev server answers without
-  # one. A request that already carries the header keeps its own, so
-  # `curl -H` can still ask as someone else. Only dev.ru uses it.
-  class DevLogin
-    # @rbs @app: untyped
-
-    LOGIN = "alpha@example.com" #: String
-
-    #: (untyped app) -> void
-    def initialize(app)
-      @app = app
-    end
-
-    #: (Rack::env env) -> untyped
-    def call(env)
-      env[ProxyAuth::ENV_KEY] ||= LOGIN
-      @app.call(env)
-    end
-  end
-end
diff --git a/lib/pro_tacts/stub_login.rb b/lib/pro_tacts/stub_login.rb
new file mode 100644
index 0000000..7f0ef56
--- /dev/null
+++ b/lib/pro_tacts/stub_login.rb
@@ -0,0 +1,36 @@
+require "pro_tacts"
+require "pro_tacts/proxy_auth"
+
+module ProTacts
+  # Rack middleware that writes a login to Remote-User, the way the proxy
+  # in front of a deployment does, so a server with nothing in front
+  # answers instead of refusing every request (ProxyAuth, Web#unauthorized).
+  # A request that already carries the header keeps its own, so `curl -H`
+  # can still ask as someone else.
+  #
+  # It stands in for the proxy, so it belongs only where there is none:
+  # the dev server (dev.ru) and the preview app, which names the login in
+  # PRO_TACTS_DEFAULT_LOGIN and config.ru inserts it. A deployment that
+  # serves real data leaves that unset and keeps the proxy as the only way
+  # in — the app trusts whatever it is handed, so writing a login here in
+  # front of one would hand every anonymous caller that address book.
+  class StubLogin
+    # @rbs @app: untyped
+    # @rbs @login: String
+
+    # The dev server's login; the preview passes its own.
+    LOGIN = "alpha@example.com" #: String
+
+    #: (untyped app, ?String login) -> void
+    def initialize(app, login = LOGIN)
+      @app = app
+      @login = login
+    end
+
+    #: (Rack::env env) -> untyped
+    def call(env)
+      env[ProxyAuth::ENV_KEY] ||= @login
+      @app.call(env)
+    end
+  end
+end
diff --git a/test/pro_tacts/test_dev_login.rb b/test/pro_tacts/test_stub_login.rb
similarity index 51%
rename from test/pro_tacts/test_dev_login.rb
rename to test/pro_tacts/test_stub_login.rb
index d801c0b..bfd17d6 100644
--- a/test/pro_tacts/test_dev_login.rb
+++ b/test/pro_tacts/test_stub_login.rb
@@ -1,20 +1,24 @@
 require_relative "../test_helper"
 
-require "pro_tacts/dev_login"
+require "pro_tacts/stub_login"
 
-class DevLoginTest < Minitest::Test
-  def login_seen(env = {})
+class StubLoginTest < Minitest::Test
+  def login_seen(env = {}, *args)
     seen = nil
     app = ->(inner) { seen = ProTacts::ProxyAuth.login(inner); [200, {}, []] }
-    ProTacts::DevLogin.new(app).call(env)
+    ProTacts::StubLogin.new(app, *args).call(env)
     seen
   end
 
-  def test_a_request_with_no_login_gets_the_dev_login
+  def test_a_request_with_no_login_gets_the_default
     assert_equal "alpha@example.com", login_seen
   end
 
   def test_a_request_naming_a_login_keeps_it
     assert_equal "zoe@example.com", login_seen("HTTP_REMOTE_USER" => "zoe@example.com")
   end
+
+  def test_the_stand_in_login_is_configurable
+    assert_equal "demo@example.com", login_seen({}, "demo@example.com")
+  end
 end