name: CD Apps on: push: branches: [main] paths: - "apps/**" - "packages/**" - "pnpm-lock.yaml" workflow_dispatch: {} concurrency: group: deploy-apps cancel-in-progress: true jobs: build-images: name: Build & Push Docker Images runs-on: ubuntu-latest environment: production permissions: contents: read packages: write strategy: matrix: app: [journal, planner] steps: - uses: actions/checkout@v6 - 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 }}:latest ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} build-args: | SENTRY_RELEASE=${{ github.sha }} secrets: | SENTRY_AUTH_TOKEN=/tmp/sentry_token deploy: name: Deploy Apps 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/app.env echo "SENTRY_RELEASE=${{ github.sha }}" >> infrastructure/app.env echo "DOMAIN=trails.cool" >> infrastructure/app.env # Flagship marker — see cd-infra.yml for what this gates. echo "IS_FLAGSHIP=true" >> infrastructure/app.env - 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.yml,infrastructure/Caddyfile" target: /opt/trails-cool strip_components: 1 - name: Copy secrets to server uses: appleboy/scp-action@v1 with: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} source: "infrastructure/app.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: | cd /opt/trails-cool # Login to ghcr.io GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN app.env | cut -d= -f2-) echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin # Pull and deploy app containers docker compose --env-file app.env pull journal planner # Hand-written data migrations (idempotent) run BEFORE drizzle-kit # push so unique-key reshapes can collapse duplicate rows first. docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force # --remove-orphans cleans up containers whose service was deleted # from the compose file, matching cd-infra's behaviour. docker compose --env-file app.env up -d --remove-orphans journal planner # Reload Caddy with the Caddyfile we just scp'd. cd-apps # ships infrastructure/Caddyfile alongside docker-compose.yml # (see scp step above), but containers don't auto-pick-up # config changes. cd-infra reloads Caddy as part of its # deploy; cd-apps did NOT, which meant any Caddyfile change # touching only `apps/`/`packages/` paths sat on disk # unapplied until the next cd-infra run. The reload is # idempotent (Caddy validates first, swaps live, no # downtime) so doing it on every cd-apps deploy is safe # even when Caddyfile is unchanged. `|| true` keeps the # deploy from failing if Caddy itself is unhealthy. docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true # Clean up docker image prune -af docker compose ps # Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives # in secrets.infra.env (decrypted by cd-infra.yml into the # merged /opt/trails-cool/.env on the server). cd-apps's # own app.env intentionally does NOT carry it — apps don't # need it at runtime. So we read from the merged `.env` # that cd-infra populated. If cd-infra has never run on # this host, .env may not exist; the `2>/dev/null` and # the `if -n` guard make the annotation a silent no-op # rather than a deploy failure in that case. GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-) if [ -n "$GRAFANA_TOKEN" ]; then docker compose exec -T grafana curl -sf -X POST \ -H "Authorization: Bearer $GRAFANA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"text":"Deploy ${{ github.sha }}","tags":["deploy","apps"]}' \ http://localhost:3000/api/annotations || true fi