| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 |
- name: "[Demo] Deploy"
- on:
- workflow_dispatch:
- inputs:
- reseed:
- description: "Reseed demo data after deploy"
- type: boolean
- required: false
- default: true
- env:
- REGISTRY: ghcr.io
- IMAGE_NAME: torqvoice/torqvoice-demo
- jobs:
- build:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- outputs:
- image: ${{ steps.meta.outputs.image }}
- steps:
- - name: Checkout demo branch
- uses: actions/checkout@v4
- with:
- ref: demo
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- - name: Get short commit SHA
- id: sha
- run: echo "short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- - name: Set image reference
- id: meta
- run: echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> "$GITHUB_OUTPUT"
- - name: Build and push demo image
- uses: docker/build-push-action@v6
- with:
- context: .
- push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:demo-${{ steps.sha.outputs.short }}
- build-args: |
- APP_VERSION=demo-${{ steps.sha.outputs.short }}
- cache-from: type=gha,scope=demo
- cache-to: type=gha,scope=demo,mode=min
- deploy:
- needs: build
- runs-on: [self-hosted, Linux, X64, hetzner]
- steps:
- - name: Checkout demo branch
- uses: actions/checkout@v4
- with:
- ref: demo
- - name: Log in to GitHub Container Registry
- run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- - name: Create deployment files
- env:
- VIRTUAL_HOST: "demo.torqvoice.com"
- DATABASE_URL: ${{ secrets.DEMO_DATABASE_URL }}
- BETTER_AUTH_SECRET: ${{ secrets.DEMO_BETTER_AUTH_SECRET }}
- # Optional. Next encrypts what a server action closes over with a key
- # made fresh by every build, so a tab left open across a deploy can
- # no longer be read by the new container. A fixed key keeps those tabs
- # working. 32 random bytes, base64: `openssl rand -base64 32`. Left
- # unset, the build's own key is used, as before.
- NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.DEMO_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
- APP_URL: "https://demo.torqvoice.com"
- DEMO_USER_EMAIL: "demo@torqvoice.com"
- DEMO_USER_PASSWORD: "demo"
- DATA_PATH: ${{ secrets.DATA_PATH }}
- POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
- POSTHOG_KEY: ${{ secrets.POSTHOG_DEMO_KEY }}
- DEMO_IMAGE: ${{ needs.build.outputs.image }}
- run: |
- mkdir -p $HOME/torqvoice-deploy/demo
- cd $HOME/torqvoice-deploy/demo
- env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|DEMO_|POSTHOG_|NEXT_SERVER_ACTIONS_)' > .env
- cat > docker-compose.yml << 'COMPOSE'
- networks:
- proxy:
- external: true
- name: proxy
- services:
- torqvoice-app-demo:
- container_name: torqvoice-app-demo
- image: ${DEMO_IMAGE}:latest
- restart: unless-stopped
- volumes:
- - ${DATA_PATH}/torqvoice_demo_data:/app/data
- expose:
- - "3000"
- environment:
- VIRTUAL_HOST: ${VIRTUAL_HOST}
- VIRTUAL_PORT: "3000"
- LETSENCRYPT_HOST: ${VIRTUAL_HOST}
- DATABASE_URL: ${DATABASE_URL}
- BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
- NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
- NEXT_PUBLIC_APP_URL: ${APP_URL}
- DEMO_MODE: "true"
- # The demo sits behind Cloudflare on the same origin as production,
- # which only accepts its edges; see deploy-prod.yml. Without this
- # every visitor shares the edge's rate-limit bucket, and the upload
- # and sign-in limits refuse strangers for each other.
- TRUST_CF_CONNECTING_IP: "true"
- DEMO_USER_EMAIL: ${DEMO_USER_EMAIL}
- DEMO_USER_PASSWORD: ${DEMO_USER_PASSWORD}
- POSTHOG_HOST: ${POSTHOG_HOST}
- POSTHOG_KEY: ${POSTHOG_KEY}
- networks:
- - proxy
- COMPOSE
- - name: Ensure data directory permissions
- run: |
- DATA_DIR="${{ secrets.DATA_PATH }}/torqvoice_demo_data"
- mkdir -p "$DATA_DIR"
- docker run --rm -v "$DATA_DIR":/data alpine chown -R 1001:1001 /data
- - name: Check free disk space
- run: |
- MIN_GB=5
- DOCKER_ROOT=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo /var/lib/docker)
- AVAIL_KB=$(df -Pk "$DOCKER_ROOT" | awk 'NR==2 {print $4}')
- AVAIL_GB=$(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}")
- echo "Free space on $DOCKER_ROOT: ${AVAIL_GB} GB (minimum ${MIN_GB} GB)"
- if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
- echo "::error::Not enough disk space to pull image: ${AVAIL_GB} GB free, need ${MIN_GB} GB. Aborting deploy."
- exit 1
- fi
- - name: Deploy demo
- run: |
- cd $HOME/torqvoice-deploy/demo
- docker compose up -d --pull always
- - name: Health check
- run: |
- # Docker reports "running" as soon as the entrypoint starts, which is
- # before init-db.sh has applied migrations. The seed step below writes
- # to tables those migrations touch, so waiting on status alone let it
- # race ahead and fail on a column that did not exist yet. The health
- # route only answers once node server.js is up, i.e. after migrating.
- echo "Waiting for demo app to serve..."
- for i in $(seq 1 60); do
- STATUS=$(docker inspect --format='{{.State.Status}}' torqvoice-app-demo 2>/dev/null || echo "not found")
- case "$STATUS" in
- running|created|restarting) ;;
- *)
- echo "::error::Container is not running (status: $STATUS)"
- docker logs torqvoice-app-demo --tail 50
- exit 1
- ;;
- esac
- if docker exec torqvoice-app-demo \
- wget -q -O /dev/null http://127.0.0.1:3000/api/v1/health 2>/dev/null; then
- echo "Demo app is healthy!"
- exit 0
- fi
- echo "Not serving yet, retrying in 5s..."
- sleep 5
- done
- echo "::error::Demo app did not become healthy in time"
- docker logs torqvoice-app-demo --tail 50
- exit 1
- - name: Seed demo data
- # workflow_dispatch is the only trigger, so the old `push` clause was
- # dead. A boolean input is a real boolean in the `inputs` context.
- if: ${{ github.event_name == 'workflow_dispatch' && inputs.reseed }}
- run: |
- docker exec \
- -e DEMO_USER_EMAIL="demo@torqvoice.com" \
- -e DEMO_USER_PASSWORD="demo" \
- -e DATA_ROOT=/app/data \
- torqvoice-app-demo \
- npx tsx prisma/seed_dummy_data.ts
- - name: Prune old images and build cache
- run: docker image prune -f && docker builder prune -f
|