release: publish a Docker image and deploy fly demos
Demo off main and per-PR previews follow the pro-tacts pattern: both run --dev fixture mode, with a daily reaper for stale previews.

Assisted-by: GLM-5.3 via pi
change txotwxtypvopkrswlsmwxnylwvtzrwsk
commit bc9c338af06102f5c1a67d878a86213849e5f6a3
author Alpha Chen <alpha@kejadlen.dev>
date
parent nxlxztxy
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..07ac987
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,3 @@
+target
+.git
+.jj
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3e224f2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,127 @@
+name: CI
+
+on:
+  push:
+    branches: [main]
+  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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+      - run: rustup component add clippy rustfmt llvm-tools
+      - run: |
+          curl -sL https://github.com/casey/just/releases/download/1.50.0/just-1.50.0-x86_64-unknown-linux-musl.tar.gz |
+            tar xz -C /usr/local/bin
+          curl -sL https://github.com/marco-c/grcov/releases/download/v0.10.7/grcov-x86_64-unknown-linux-gnu.tar.bz2 |
+            tar xj -C /usr/local/bin
+      - run: cargo fmt --check
+      - run: just clippy coverage
+
+  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
+      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
+        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
+        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
+        with:
+          context: .
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          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 "v${STEPS_META_OUTPUTS_VERSION}" --target "$GITHUB_SHA" --generate-notes
+
+  deploy:
+    name: Deploy demo
+    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://docket.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: docket
+      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/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..69ec0ad
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,41 @@
+# Cargo-chef stage for dependency caching.
+FROM rust:1.95-trixie AS chef
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git/db \
+    cargo install --locked cargo-chef
+WORKDIR /build
+
+# Plan stage: inspect source and produce a recipe of dependencies.
+FROM chef AS planner
+COPY . .
+RUN cargo chef prepare --recipe-path recipe.json
+
+# Build stage: cook dependencies first (cached across rebuilds), then build
+# the binary. Changes to source code do not retrigger dependency compilation.
+FROM chef AS builder
+COPY --from=planner /build/recipe.json recipe.json
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git/db \
+    --mount=type=cache,target=/build/target \
+    cargo chef cook --release --recipe-path recipe.json
+COPY . .
+# Copy the binary out of the cache mount so it survives into the runtime
+# stage.
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git/db \
+    --mount=type=cache,target=/build/target \
+    cargo build --release --bin docket && \
+    cp target/release/docket /build/docket
+
+# Runtime stage.
+FROM debian:trixie-slim
+
+# JMAP over TLS.
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /build/docket /usr/local/bin/docket
+
+EXPOSE 3000
+ENTRYPOINT ["docket"]
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..5537373
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,37 @@
+# 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). Both run the binary with --dev, which is
+# the whole of what makes them demos: fixture data instead of JMAP, and
+# a stand-in identity in place of the Tailscale-User-Login header that
+# tailscale serve supplies in front of the real deployment. No volume,
+# so every deploy starts from the fixtures again.
+#
+# 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 = 'docket'
+primary_region = 'sjc'
+
+[build]
+
+[env]
+# Fly's edge routes to the machine, not to localhost.
+DOCKET_BIND = '0.0.0.0:3000'
+
+[processes]
+app = '--dev'
+
+[http_service]
+  internal_port = 3000
+  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