Align CI and deployment with ketchup and pro-tacts
Lock down and pin the CI workflow, add a zizmor audit, and on main build
an image to GHCR, cut a release, and deploy it to Fly. Add per-PR Fly
previews with a nightly reaper, a Dockerfile, Renovate, and the ramekin
and quire configs the other projects carry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014U2t8JuR681EA8GQD9z5mD
change
commit 779e5150e57ac2e836f1e0dbbe6609f1defceb29
author Claude <noreply@anthropic.com>
date
parent rstwlmuo
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a2ffc7a
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git
+.jj
+.github
+.claude
+.direnv
+.envrc
+.quire
+.ramekin
+db/*.db
+test
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 39ea8fa..2e2356d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,12 +6,120 @@ on:
   pull_request:
     branches: [main]
 
+permissions: {}
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
 jobs:
   test:
+    name: Test
     runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout
     steps:
-      - uses: actions/checkout@v6
-      - uses: ruby/setup-ruby@v1
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+      - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1
         with:
           bundler-cache: true
-      - run: bundle exec rake test
+      - run: bundle exec rake
+
+  zizmor:
+    name: Audit workflows
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout
+      security-events: write # upload SARIF to code scanning
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+      - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
+
+  build:
+    name: Build
+    needs: test
+    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write # create GitHub release
+      packages: write # push container image to GHCR
+    outputs:
+      version: ${{ steps.meta.outputs.version }}
+      image: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }}
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+      - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
+      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - id: short-sha
+        run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
+      - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
+        id: meta
+        with:
+          images: ghcr.io/${{ github.repository }}
+          flavor: latest=true
+          tags: |
+            type=raw,value={{date 'YYYYMMDD-HHmm'}}-${{ steps.short-sha.outputs.sha }}
+      - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+        with:
+          context: .
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          build-args: |
+            VERSION=${{ steps.meta.outputs.version }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+      - env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          STEPS_META_OUTPUTS_VERSION: ${{ steps.meta.outputs.version }}
+        # --target names the commit explicitly: persist-credentials is
+        # false, so the tag cannot ride the leftover git credentials.
+        run: gh release create "${STEPS_META_OUTPUTS_VERSION}" --target "$GITHUB_SHA" --generate-notes
+
+  deploy:
+    name: Deploy main preview
+    needs: build
+    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout, for fly.toml
+    environment:
+      name: demo
+      url: https://rank-king.fly.dev
+    # One deploy at a time, and a newer main is the one worth having.
+    concurrency:
+      group: fly-deploy
+      cancel-in-progress: true
+    env:
+      FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+      FLY_APP: rank-king
+      FLY_ORG: personal
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+
+      - uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6
+
+      # Created here rather than by hand, so the app is something this
+      # repository can lose and get back. `status` is how the review-app
+      # action asks the same question.
+      - name: Create the app if it is not there yet
+        run: flyctl status --app "$FLY_APP" >/dev/null 2>&1 || flyctl apps create "$FLY_APP" --org "$FLY_ORG"
+
+      # The image the build job just pushed, rather than a second build
+      # of the same commit: what the demo runs is then the release.
+      - name: Deploy the released image
+        env:
+          NEEDS_BUILD_OUTPUTS_IMAGE: ${{ needs.build.outputs.image }}
+        run: flyctl deploy --app "$FLY_APP" --config fly.toml --image "$NEEDS_BUILD_OUTPUTS_IMAGE"
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
new file mode 100644
index 0000000..12284e4
--- /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@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6
+
+      # 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.toml
+          memory: 256
diff --git a/.github/workflows/reap-previews.yml b/.github/workflows/reap-previews.yml
new file mode 100644
index 0000000..4678eb7
--- /dev/null
+++ b/.github/workflows/reap-previews.yml
@@ -0,0 +1,147 @@
+name: Reap previews
+
+on:
+  schedule:
+    - cron: "17 8 * * *"
+  workflow_dispatch:
+    inputs:
+      dry_run:
+        description: Report what would be destroyed without destroying it
+        type: boolean
+        default: false
+      stale_days:
+        description: Also reap previews for open PRs untouched this many days (0 disables)
+        type: string
+        default: "7"
+
+permissions: {}
+
+concurrency:
+  group: ${{ github.workflow }}
+  cancel-in-progress: false
+
+jobs:
+  reap:
+    name: Reap previews
+    runs-on: ubuntu-latest
+    permissions:
+      pull-requests: read # read PR state to decide which previews are finished
+    environment: preview-reaper # holds FLY_API_TOKEN access, as demo/pr-* do
+    steps:
+      - uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6
+
+      - name: Destroy previews for finished pull requests
+        env:
+          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+          FLY_ORG: personal
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          REPO: ${{ github.repository }}
+          OWNER: ${{ github.repository_owner }}
+          DRY_RUN: ${{ inputs.dry_run }}
+          STALE_DAYS: ${{ inputs.stale_days || '7' }}
+        run: |
+          set -euo pipefail
+
+          # Preview apps are named by superfly/fly-pr-review-apps as
+          # pr-<number>-<owner>-<repo>, with underscores turned into hyphens.
+          repo_name="${REPO#"${OWNER}/"}"
+          suffix="-$(printf '%s' "${OWNER}-${repo_name}" | tr '_' '-')"
+
+          # Prove the token can read this repo before trusting any 404 below to
+          # mean "that pull request is gone" rather than "the token is broken".
+          gh api "repos/${REPO}" --jq .full_name > /dev/null
+
+          cutoff=""
+          if [ "${STALE_DAYS}" != "0" ]; then
+            cutoff=$(date -u -d "${STALE_DAYS} days ago" +%s)
+          fi
+
+          destroyed=0
+          kept=0
+          {
+            echo "| Preview | Pull request | Outcome |"
+            echo "| --- | --- | --- |"
+          } >> "${GITHUB_STEP_SUMMARY}"
+
+          apps=$(flyctl apps list --org "${FLY_ORG}" --json | jq -r '.[] | (.Name // .name)')
+
+          while IFS= read -r app; do
+            [ -n "${app}" ] || continue
+            case "${app}" in
+              pr-*"${suffix}") ;;
+              *) continue ;;
+            esac
+
+            pr="${app#pr-}"
+            pr="${pr%"${suffix}"}"
+            case "${pr}" in
+              "" | *[!0-9]*) continue ;;
+            esac
+
+            if body=$(gh api "repos/${REPO}/pulls/${pr}" 2> "${RUNNER_TEMP}/gh-err"); then
+              state=$(printf '%s' "${body}" | jq -r .state)
+              merged=$(printf '%s' "${body}" | jq -r '.merged_at != null')
+              updated=$(printf '%s' "${body}" | jq -r .updated_at)
+            elif grep -q 'HTTP 404' "${RUNNER_TEMP}/gh-err"; then
+              state="missing"
+              merged="false"
+              updated=""
+            else
+              echo "::warning::Could not read PR #${pr}; leaving ${app} alone."
+              cat "${RUNNER_TEMP}/gh-err"
+              kept=$((kept + 1))
+              echo "| ${app} | #${pr} | kept (lookup failed) |" >> "${GITHUB_STEP_SUMMARY}"
+              continue
+            fi
+
+            reason=""
+            case "${state}" in
+              closed)
+                if [ "${merged}" = "true" ]; then
+                  reason="PR merged"
+                else
+                  reason="PR closed"
+                fi
+                ;;
+              missing)
+                reason="PR no longer exists"
+                ;;
+              open)
+                if [ -n "${cutoff}" ] && [ "$(date -u -d "${updated}" +%s)" -lt "${cutoff}" ]; then
+                  reason="PR open but untouched since ${updated%T*}"
+                fi
+                ;;
+            esac
+
+            if [ -z "${reason}" ]; then
+              kept=$((kept + 1))
+              echo "| ${app} | #${pr} | kept (${state}) |" >> "${GITHUB_STEP_SUMMARY}"
+              continue
+            fi
+
+            # Same guard the deploy action uses: never destroy an app whose name
+            # does not carry the PR number we looked up.
+            case "${app}" in
+              *"${pr}"*) ;;
+              *)
+                echo "::error::Refusing to destroy ${app}: name does not contain PR #${pr}."
+                exit 1
+                ;;
+            esac
+
+            if [ "${DRY_RUN}" = "true" ]; then
+              echo "Would destroy ${app} (${reason})."
+              echo "| ${app} | #${pr} | would destroy (${reason}) |" >> "${GITHUB_STEP_SUMMARY}"
+              continue
+            fi
+
+            echo "Destroying ${app} (${reason})."
+            flyctl apps destroy "${app}" --yes
+            destroyed=$((destroyed + 1))
+            echo "| ${app} | #${pr} | destroyed (${reason}) |" >> "${GITHUB_STEP_SUMMARY}"
+          done <<EOF
+          ${apps}
+          EOF
+
+          echo "" >> "${GITHUB_STEP_SUMMARY}"
+          echo "Destroyed ${destroyed}, kept ${kept}." >> "${GITHUB_STEP_SUMMARY}"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2e274d9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+db/*.db
diff --git a/.quire/config.fnl b/.quire/config.fnl
new file mode 100644
index 0000000..7ef0fd2
--- /dev/null
+++ b/.quire/config.fnl
@@ -0,0 +1 @@
+{:mirrors {"https://github.com/kejadlen/rank-king.git" :github-mirror}}
diff --git a/.ramekin/Dockerfile b/.ramekin/Dockerfile
new file mode 100644
index 0000000..1e287b7
--- /dev/null
+++ b/.ramekin/Dockerfile
@@ -0,0 +1,45 @@
+# Ramekin image for rank-king (Ruby 4 webapp).
+#
+# FROM ramekin-agent to keep the pi harness. Switch to ramekin-base for a
+# leaner image without the agent.
+#
+# Ruby 4 is not in trixie's apt (it ships 3.x), so Ruby is built from source.
+# Adjust RUBY_VERSION if the released patch differs from the default.
+
+FROM ramekin-agent
+
+ARG RUBY_VERSION=4.0.0
+ENV RUBY_VERSION=${RUBY_VERSION}
+
+# Toolchain and headers for building Ruby and for any native gem that lacks a
+# precompiled binary (sqlite3 and puma usually ship one, but keep these).
+# locales so Ruby reads source as UTF-8 rather than US-ASCII.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+      build-essential autoconf bison \
+      libssl-dev libyaml-dev libreadline-dev zlib1g-dev libffi-dev \
+      ca-certificates wget locales \
+ && rm -rf /var/lib/apt/lists/*
+
+# A locale named in the environment but never generated is as good as absent
+# — Ruby falls back to US-ASCII — so generate it and set it as the default.
+RUN sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen
+ENV LANG=en_US.UTF-8 \
+    LC_ALL=en_US.UTF-8
+
+RUN wget -q "https://cache.ruby-lang.org/pub/ruby/${RUBY_VERSION%.*}/ruby-${RUBY_VERSION}.tar.gz" \
+ && tar -xzf "ruby-${RUBY_VERSION}.tar.gz" \
+ && cd "ruby-${RUBY_VERSION}" \
+ && ./configure --disable-install-doc \
+ && make -j"$(nproc)" \
+ && make install \
+ && cd / && rm -rf "ruby-${RUBY_VERSION}" "ruby-${RUBY_VERSION}.tar.gz"
+
+# Match the bundler pinned in Gemfile.lock (BUNDLED WITH).
+RUN gem install bundler -v 4.0.3
+
+# Install project gems at build time so bundle/rake/puma are ready each session.
+# Build context is expected to be the project root.
+COPY Gemfile Gemfile.lock /tmp/bundle/
+RUN cd /tmp/bundle \
+ && bundle install --jobs 4 \
+ && rm -rf /tmp/bundle
diff --git a/.ramekin/config.kdl b/.ramekin/config.kdl
new file mode 100644
index 0000000..ff00d2d
--- /dev/null
+++ b/.ramekin/config.kdl
@@ -0,0 +1,5 @@
+// The workspace .envrc is shadowed by /dev/null in the user config, so the
+// vars it exports have to be restated here to reach the agent.
+env {
+  RANGER_DEFAULT_BACKLOG "rank-king"
+}
diff --git a/AGENTS.md b/AGENTS.md
index 2f2bae8..8814815 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,7 +27,10 @@ lib/rank_king/     — app code
 lib/open_skill/    — OpenSkill rating library (pure Ruby)
 test/              — Minitest tests
 public/            — static assets
-.github/workflows/ — CI (GitHub Actions)
+.github/workflows/ — CI, PR previews, preview reaper (GitHub Actions)
+Dockerfile         — production image
+fly.toml           — Fly config for the main demo and PR previews
+.ramekin/          — agent dev container
 ```
 
 ## Domain model
@@ -45,6 +48,19 @@ public/            — static assets
 | Run dev server | `bundle exec rake dev` (uses `fd \| entr -r rackup`) |
 | Start server | `bundle exec rackup` |
 
+## CI and deployment
+
+- `ci.yml` runs `rake` and audits workflows with zizmor on every push and PR.
+  On main it then builds the image, pushes it to GHCR, cuts a GitHub release
+  named after the image tag, and deploys that image to Fly (`rank-king`).
+- `preview.yml` deploys each PR to its own Fly app and destroys it on close;
+  `reap-previews.yml` cleans up any that were missed.
+- Needs a `FLY_API_TOKEN` secret, available to the `demo`, `pr-*`, and
+  `preview-reaper` environments.
+- Workflow actions are pinned by commit with a tag comment naming that
+  commit, never a branch. Renovate keeps them, gems, and the Docker base
+  image current.
+
 ## Environment variables
 
 - `DATABASE_URL` — SQLite path (default: `:memory:`)
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4c60492
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+FROM ruby:4.0-slim AS build
+
+RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY Gemfile Gemfile.lock ./
+RUN bundle config set without development && bundle install
+
+COPY . .
+
+FROM ruby:4.0-slim
+
+# The image's own tag, and the release's name (see ci.yml).
+ARG VERSION
+ENV VERSION=${VERSION}
+# rackup defaults to development, which wraps the app in Rack::Lint and
+# ShowExceptions.
+ENV RACK_ENV=production
+
+WORKDIR /app
+
+COPY --from=build /usr/local/bundle /usr/local/bundle
+COPY --from=build /app /app
+
+EXPOSE 9292
+
+CMD ["rackup", "-o", "0.0.0.0"]
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..aee9e8b
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,27 @@
+# The one Fly config, for the two demo apps: the app main deploys (the
+# deploy job in .github/workflows/ci.yml) and the per-PR previews (the
+# review-app action in .github/workflows/preview.yml, which overrides
+# the app name with the PR's). DATABASE_URL defaults to :memory:, so
+# every deploy starts empty.
+#
+# The app name is Fly's, not GitHub's, so it has to be unique across all
+# of Fly and a taken one fails the deploy. ci.yml spells it twice more,
+# as FLY_APP to create it on the first deploy and in the environment's
+# URL, so a rename here is a rename in three places.
+app = 'rank-king'
+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
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..717e3eb
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,10 @@
+{
+  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+  "extends": ["config:recommended"],
+  "packageRules": [
+    {
+      "matchManagers": ["bundler", "dockerfile", "github-actions"],
+      "schedule": ["every weekend"]
+    }
+  ]
+}