Add staging + PR-preview environments on the flagship

Implements the staging-environments OpenSpec change. Persistent staging at
staging.trails.cool / planner.staging.trails.cool deploys from main; PR
opens get a journal-only preview at pr-<N>.staging.trails.cool that shares
the persistent planner. cd-staging.yml builds tagged images, manages
per-PR Postgres databases and Caddyfile snippets, evicts the oldest
preview at the cap of 3, and tears everything down on PR close.
staging-cleanup.yml runs weekly to sweep orphaned previews.

DNS records (staging + *.staging A/AAAA) already applied to production
via tofu.

Caddy approach: per-PR Caddyfile snippets imported from /etc/caddy/sites/
and reloaded on each PR event — no wildcard / on-demand TLS, no router
service. Production compose gains a trails-shared network for the staging
project to reach Postgres, and host.docker.internal on Caddy so it can
reverse-proxy staging containers published on the host loopback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-03 22:03:38 +02:00
parent 795ec2215c
commit c8a7a0b253
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
11 changed files with 818 additions and 29 deletions

402
.github/workflows/cd-staging.yml vendored Normal file
View file

@ -0,0 +1,402 @@
name: CD Staging
# Builds, deploys, and tears down the persistent staging stack and per-PR
# preview environments. See `openspec/changes/staging-environments/` for the
# design (decisions on shared Postgres, port allocation, per-PR Caddyfile
# snippets) and CLAUDE.md "Staging & Previews" for the operator-facing view.
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
pull_request:
types: [opened, synchronize, reopened, closed]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
workflow_dispatch: {}
# Per-target concurrency: persistent staging deploys serialize against
# themselves, each PR's preview lifecycle serializes against itself.
concurrency:
group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }}
cancel-in-progress: true
jobs:
# ── Build ─────────────────────────────────────────────────────────────
# Tags:
# main push → :staging + :<sha>
# PR open/sync/reopen → :pr-<N> + :pr-<N>-<sha>
# Skipped entirely on PR close (teardown doesn't need new images).
build-images:
name: Build & Push Docker Images
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
strategy:
matrix:
app: [journal, planner]
outputs:
tag_primary: ${{ steps.tags.outputs.primary }}
tag_sha: ${{ steps.tags.outputs.sha }}
steps:
- uses: actions/checkout@v6
- id: tags
name: Compute image tags
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
PRIMARY="pr-${{ github.event.number }}"
SHA="pr-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}"
else
PRIMARY="staging"
SHA="${{ github.sha }}"
fi
echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Decrypt Sentry auth token
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env
grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token
- uses: docker/build-push-action@v7
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
push: true
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.primary }}
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }}
build-args: |
SENTRY_RELEASE=${{ steps.tags.outputs.sha }}
secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token
# ── Deploy persistent staging (main push) ────────────────────────────
deploy-staging:
name: Deploy Staging
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [build-images]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v6
- name: Decrypt secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env
{
echo "DOMAIN=staging.trails.cool"
echo "STAGING_DATABASE=trails_staging"
echo "JOURNAL_HOST_PORT=3100"
echo "PLANNER_HOST_PORT=3101"
echo "JOURNAL_IMAGE_TAG=staging"
echo "PLANNER_IMAGE_TAG=staging"
echo "SENTRY_RELEASE=${{ github.sha }}"
} >> infrastructure/staging.env
- name: Copy compose file + env to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env"
target: /opt/trails-cool
strip_components: 1
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
cd /opt/trails-cool
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Bootstrap the shared network so cd-staging works regardless of
# whether cd-infra has already run with the new docker-compose.yml.
# Once cd-infra runs, postgres is permanently joined via compose;
# until then, attach it imperatively.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
if [ -n "$PG_CONTAINER" ]; then
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
fi
# Ensure trails_staging database exists with postgis. Production
# init scripts only run on first data-dir init, so a freshly
# created database has no extensions.
docker compose exec -T postgres psql -U trails -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='trails_staging'" \
| grep -q 1 \
|| docker compose exec -T postgres createdb -U trails trails_staging
docker compose exec -T postgres psql -U trails -d trails_staging -c \
"CREATE EXTENSION IF NOT EXISTS postgis"
# Pull and deploy staging containers (journal + planner via "persistent" profile)
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans
# Reload Caddy so new staging routes (or Caddyfile changes shipped
# via cd-infra) are live. Idempotent.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
docker compose -f docker-compose.staging.yml -p trails-staging ps
# ── PR preview deploy ────────────────────────────────────────────────
deploy-preview:
name: Deploy PR Preview
if: github.event_name == 'pull_request' && github.event.action != 'closed'
needs: [build-images]
runs-on: ubuntu-latest
environment: production
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v6
- id: ports
name: Compute preview ports + project name
run: |
PR=${{ github.event.number }}
# journal = 3200 + 2N, planner unused (PR previews are journal-only)
JOURNAL_PORT=$((3200 + 2 * PR))
PLANNER_PORT=$((3201 + 2 * PR))
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "journal_port=$JOURNAL_PORT" >> "$GITHUB_OUTPUT"
echo "planner_port=$PLANNER_PORT" >> "$GITHUB_OUTPUT"
echo "host=pr-$PR.staging.trails.cool" >> "$GITHUB_OUTPUT"
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- name: Decrypt secrets + assemble env
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env
{
echo "DOMAIN=${{ steps.ports.outputs.host }}"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=${{ steps.ports.outputs.journal_port }}"
echo "PLANNER_HOST_PORT=${{ steps.ports.outputs.planner_port }}"
echo "JOURNAL_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
echo "PLANNER_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
# PR-preview journals all share the persistent staging planner.
echo "PLANNER_URL=https://planner.staging.trails.cool"
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
} >> infrastructure/staging.env
- name: Generate per-PR Caddyfile snippet
run: |
mkdir -p infrastructure/sites
cat > infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile <<EOF
# Auto-generated by cd-staging.yml for PR ${{ steps.ports.outputs.pr }}. Do not edit.
${{ steps.ports.outputs.host }} {
import security_headers
import block_scanners
log {
output stdout
format json
}
reverse_proxy host.docker.internal:${{ steps.ports.outputs.journal_port }} {
lb_try_duration 30s
lb_try_interval 250ms
}
}
EOF
- name: Copy files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile"
target: /opt/trails-cool
strip_components: 1
- name: Deploy preview via SSH
uses: appleboy/ssh-action@v1
env:
PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB
script: |
set -euo pipefail
cd /opt/trails-cool
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Same network bootstrap as deploy-staging — see comment there.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
if [ -n "$PG_CONTAINER" ]; then
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
fi
# Concurrent preview limit (max 3): if we're at the cap and this
# PR isn't already running, evict the oldest preview project.
ACTIVE=$(docker compose ls --format json --filter "name=trails-pr-" | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\n".join(d["Name"] for d in data))' 2>/dev/null || true)
if [ -n "$ACTIVE" ] && ! echo "$ACTIVE" | grep -qx "$PROJECT"; then
COUNT=$(echo "$ACTIVE" | grep -c '^trails-pr-' || true)
if [ "$COUNT" -ge 3 ]; then
# Pick the oldest by container CreatedAt of any service in the project.
OLDEST=$(docker ps -a --filter "name=trails-pr-" --format '{{.Names}} {{.CreatedAt}}' \
| awk '{ split($1,a,"-"); print "trails-pr-"a[3], $2" "$3" "$4 }' \
| sort -k2 \
| head -1 \
| awk '{print $1}')
if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then
echo "At cap; evicting oldest preview: $OLDEST"
OLD_PR=${OLDEST#trails-pr-}
docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file staging.env down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true
rm -f "sites/pr-$OLD_PR.caddyfile"
fi
fi
fi
# Ensure per-PR database exists with postgis. (See deploy-staging
# for why we have to enable the extension explicitly.)
docker compose exec -T postgres psql -U trails -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='$DB'" \
| grep -q 1 \
|| docker compose exec -T postgres createdb -U trails "$DB"
docker compose exec -T postgres psql -U trails -d "$DB" -c \
"CREATE EXTENSION IF NOT EXISTS postgis"
# Pull, migrate, deploy (journal-only — no --profile means planner skipped)
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env pull journal
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env up -d --remove-orphans journal
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
docker compose -f docker-compose.staging.yml -p "$PROJECT" ps
- name: Comment preview URL on PR
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.number }}
body: |
🚀 **PR preview deployed**
- **Journal:** https://${{ steps.ports.outputs.host }}
- **Planner (shared staging):** https://planner.staging.trails.cool
- **Database:** `${{ steps.ports.outputs.database }}` (separate from production / persistent staging)
Updates automatically on push. Tears down when this PR closes.
# ── PR preview teardown ──────────────────────────────────────────────
teardown-preview:
name: Tear Down PR Preview
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
environment: production
permissions:
pull-requests: write
steps:
- id: ports
name: Compute project + database name
run: |
PR=${{ github.event.number }}
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v6
- name: Decrypt secrets (needed to satisfy compose env vars during down)
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env
{
echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))"
echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))"
} >> infrastructure/staging.env
- name: Copy compose + env (teardown still needs the file)
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env"
target: /opt/trails-cool
strip_components: 1
- name: Tear down via SSH
uses: appleboy/ssh-action@v1
env:
PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB
script: |
set -euo pipefail
cd /opt/trails-cool
# Stop and remove containers + volumes for this PR
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env down --remove-orphans || true
# Drop the per-PR database (idempotent)
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
# Remove the per-PR Caddy snippet and reload
rm -f "sites/pr-$PR.caddyfile"
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
- name: Find existing preview comment
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.number }}
comment-author: "github-actions[bot]"
body-includes: "PR preview deployed"
- name: Update preview comment on close
if: steps.find-comment.outputs.comment-id
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
🧹 PR preview torn down (PR ${{ github.event.action == 'closed' && github.event.pull_request.merged && 'merged' || 'closed' }}).

112
.github/workflows/staging-cleanup.yml vendored Normal file
View file

@ -0,0 +1,112 @@
name: Staging Cleanup
# Sweeps the production server for orphaned PR-preview resources whose PRs
# have closed without the cd-staging teardown job running (e.g., the
# teardown failed, the workflow file was changed mid-flight, or the PR was
# closed while runners were down). Runs weekly and can be triggered ad-hoc.
on:
schedule:
# Every Monday at 04:00 UTC
- cron: "0 4 * * 1"
workflow_dispatch: {}
concurrency:
group: staging-cleanup
cancel-in-progress: false
jobs:
cleanup:
name: Sweep orphaned previews
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
pull-requests: read
steps:
- name: List active preview projects
id: list
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
script: |
cd /opt/trails-cool
# Emit one project name per line, e.g. "trails-pr-123"
docker compose ls --format json --filter "name=trails-pr-" \
| python3 -c 'import json,sys
try:
data = json.load(sys.stdin)
except Exception:
data = []
for d in data:
n = d.get("Name","")
if n.startswith("trails-pr-"):
print(n)' \
|| true
- name: Determine which PRs are still open
id: orphans
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROJECTS: ${{ steps.list.outputs.stdout }}
run: |
set -euo pipefail
ORPHANS=()
if [ -z "${PROJECTS:-}" ]; then
echo "No active preview projects."
echo "orphans=" >> "$GITHUB_OUTPUT"
exit 0
fi
while IFS= read -r project; do
[ -z "$project" ] && continue
pr="${project#trails-pr-}"
# If gh can't find the PR (deleted) or it's not OPEN, treat as orphan.
state=$(gh pr view "$pr" --repo "${{ github.repository }}" --json state -q .state 2>/dev/null || echo "MISSING")
if [ "$state" != "OPEN" ]; then
echo "Orphan: PR #$pr (state=$state) → tear down $project"
ORPHANS+=("$pr")
fi
done <<< "${PROJECTS}"
IFS=,
echo "orphans=${ORPHANS[*]:-}" >> "$GITHUB_OUTPUT"
- name: Tear down orphans
if: steps.orphans.outputs.orphans != ''
env:
ORPHANS: ${{ steps.orphans.outputs.orphans }}
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: ORPHANS
script: |
set -euo pipefail
cd /opt/trails-cool
IFS=, read -ra PRS <<< "$ORPHANS"
for PR in "${PRS[@]}"; do
[ -z "$PR" ] && continue
PROJECT="trails-pr-$PR"
DB="trails_pr_$PR"
echo "→ tearing down $PROJECT"
# `down` needs the same env file the deploy used; staging.env on
# disk may belong to a different PR, so synthesize a minimal one.
cat > /tmp/cleanup.env <<EOF
DOMAIN=pr-$PR.staging.trails.cool
STAGING_DATABASE=$DB
JOURNAL_HOST_PORT=$((3200 + 2 * PR))
PLANNER_HOST_PORT=$((3201 + 2 * PR))
JWT_SECRET=cleanup
SESSION_SECRET=cleanup
BROUTER_URL=http://placeholder
BROUTER_AUTH_TOKEN=placeholder
EOF
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file /tmp/cleanup.env down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
rm -f "sites/pr-$PR.caddyfile"
done
rm -f /tmp/cleanup.env
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true