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
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"]
+ }
+ ]
+}