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
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