deploy-prod.yml 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. name: "[Prod] Deploy"
  2. on:
  3. workflow_dispatch:
  4. inputs:
  5. tag:
  6. description: "Version tag to deploy (e.g. v1.2.39). Leave blank to deploy the newest published release. 'latest' is refused."
  7. required: false
  8. default: ""
  9. jobs:
  10. deploy:
  11. runs-on: [self-hosted, Linux, X64, hetzner]
  12. steps:
  13. # Only for .github/scripts; the app itself arrives as an image.
  14. - uses: actions/checkout@v4
  15. with:
  16. sparse-checkout: .github/scripts
  17. - name: Resolve version tag
  18. id: tag
  19. run: |
  20. TAG="${{ inputs.tag }}"
  21. if [ -z "$TAG" ]; then
  22. # Newest published release (drafts are excluded, so publishing the
  23. # release in GitHub is the gate that makes it deployable).
  24. TAG=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
  25. "https://api.github.com/repos/${{ github.repository }}/releases/latest" \
  26. | grep -m1 '"tag_name"' | cut -d'"' -f4)
  27. echo "Resolved newest published release: $TAG"
  28. fi
  29. if [ -z "$TAG" ] || [ "$TAG" = "latest" ]; then
  30. echo "::error::No deployable version tag ('latest' is refused; rollback needs a named target)."
  31. exit 1
  32. fi
  33. echo "value=$TAG" >> "$GITHUB_OUTPUT"
  34. - name: Log in to GitHub Container Registry
  35. run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
  36. - name: Create deployment files
  37. env:
  38. VIRTUAL_HOST: ${{ secrets.CLOUD_VIRTUAL_HOST }}
  39. DATABASE_URL: ${{ secrets.CLOUD_DATABASE_URL }}
  40. BETTER_AUTH_SECRET: ${{ secrets.CLOUD_BETTER_AUTH_SECRET }}
  41. APP_URL: ${{ secrets.CLOUD_APP_URL }}
  42. # Plans are sold on torqvoice.com. This secret is the one torqvoice.com
  43. # holds as TORQVOICE_SERVICE_SECRET; the app signs checkout handoffs
  44. # with it and authenticates billing calls with it. The Stripe keys
  45. # themselves live in this app's database and are edited from the
  46. # torqvoice.com admin, so they are no longer passed here.
  47. TORQVOICE_SERVICE_SECRET: ${{ secrets.TORQVOICE_SERVICE_SECRET }}
  48. # Signed by torqvoice.com for APP_URL (scripts/mint-cloud-token.mjs there).
  49. # Without it the app ignores TORQVOICE_MODE=cloud and runs self-hosted.
  50. TORQVOICE_CLOUD_TOKEN: ${{ secrets.CLOUD_TORQVOICE_CLOUD_TOKEN }}
  51. DATA_PATH: ${{ secrets.DATA_PATH }}
  52. POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
  53. POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
  54. BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
  55. INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
  56. # Customer replies by email (Messages > email channel). The domain SES
  57. # receives mail for, the bucket its receipt rule writes into, the
  58. # region of both, and the topic whose deliveries the webhook accepts.
  59. # All four or none: with any missing the channel stays off and
  60. # customer mail goes out without a reply address, as before.
  61. INBOUND_MAIL_DOMAIN: ${{ secrets.CLOUD_INBOUND_MAIL_DOMAIN }}
  62. INBOUND_MAIL_BUCKET: ${{ secrets.CLOUD_INBOUND_MAIL_BUCKET }}
  63. INBOUND_MAIL_REGION: ${{ secrets.CLOUD_INBOUND_MAIL_REGION }}
  64. INBOUND_MAIL_TOPIC_ARN: ${{ secrets.CLOUD_INBOUND_MAIL_TOPIC_ARN }}
  65. # Backups by Torqvoice: one restic repository per Pro workshop in a bucket of ours.
  66. # Endpoint, bucket, access key, secret key and master key go together, or the schedule
  67. # stays off. The master key derives every repository password: keep it in the DR vault.
  68. # Region is optional and defaults to auto, right for Cloudflare R2; set it for a
  69. # regional bucket anywhere else. The alert email is optional.
  70. PLATFORM_BACKUP_S3_ENDPOINT: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_ENDPOINT }}
  71. PLATFORM_BACKUP_S3_BUCKET: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_BUCKET }}
  72. PLATFORM_BACKUP_S3_REGION: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_REGION }}
  73. PLATFORM_BACKUP_S3_ACCESS_KEY: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_ACCESS_KEY }}
  74. PLATFORM_BACKUP_S3_SECRET_KEY: ${{ secrets.CLOUD_PLATFORM_BACKUP_S3_SECRET_KEY }}
  75. PLATFORM_BACKUP_MASTER_KEY: ${{ secrets.CLOUD_PLATFORM_BACKUP_MASTER_KEY }}
  76. PLATFORM_BACKUP_ALERT_EMAIL: ${{ secrets.CLOUD_PLATFORM_BACKUP_ALERT_EMAIL }}
  77. # Optional, a repository variable rather than a secret: the largest backup data
  78. # file a restore reads into memory, in MiB (128 unless set, see .env.example).
  79. BACKUP_MAX_DATA_FILE_MB: ${{ vars.CLOUD_BACKUP_MAX_DATA_FILE_MB }}
  80. # Optional. Next encrypts what a server action closes over with this
  81. # key; left unset, the key the image was built with is used. It does
  82. # not keep open tabs working across a deploy: action IDs are fixed
  83. # when the image is built, from the build's key (see the Dockerfile).
  84. # 32 random bytes, base64: `openssl rand -base64 32`.
  85. NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
  86. GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_ID }}
  87. GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_SECRET }}
  88. GOOGLE_AUTH_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_ID }}
  89. GOOGLE_AUTH_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_SECRET }}
  90. MICROSOFT_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_MICROSOFT_INTEGRATION_CLIENT_ID }}
  91. MICROSOFT_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_MICROSOFT_INTEGRATION_CLIENT_SECRET }}
  92. ZOOM_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_ID }}
  93. ZOOM_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_SECRET }}
  94. QUICKBOOKS_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_ID }}
  95. QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
  96. FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_ID }}
  97. FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_SECRET }}
  98. run: |
  99. mkdir -p $HOME/torqvoice-deploy/prod
  100. cd $HOME/torqvoice-deploy/prod
  101. env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
  102. cat > docker-compose.yml << 'COMPOSE'
  103. networks:
  104. proxy:
  105. external: true
  106. name: proxy
  107. services:
  108. torqvoice-app:
  109. container_name: torqvoice-app
  110. image: ghcr.io/torqvoice/torqvoice:${APP_TAG:-latest}
  111. restart: unless-stopped
  112. volumes:
  113. - ${DATA_PATH}/torqvoice_data:/app/data
  114. expose:
  115. - "3000"
  116. environment:
  117. VIRTUAL_HOST: ${VIRTUAL_HOST}
  118. VIRTUAL_PORT: "3000"
  119. LETSENCRYPT_HOST: ${VIRTUAL_HOST}
  120. DATABASE_URL: ${DATABASE_URL}
  121. BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
  122. NEXT_PUBLIC_APP_URL: ${APP_URL}
  123. TORQVOICE_MODE: cloud
  124. # Production sits behind Cloudflare and the origin only accepts its
  125. # edges, so the visitor address is the one Cloudflare says it is.
  126. # Without this every visitor shares the edge's rate-limit bucket.
  127. TRUST_CF_CONNECTING_IP: "true"
  128. TORQVOICE_SERVICE_SECRET: ${TORQVOICE_SERVICE_SECRET}
  129. TORQVOICE_CLOUD_TOKEN: ${TORQVOICE_CLOUD_TOKEN}
  130. TORQVOICE_COM_URL: https://torqvoice.com
  131. POSTHOG_HOST: ${POSTHOG_HOST}
  132. POSTHOG_KEY: ${POSTHOG_KEY}
  133. BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
  134. INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
  135. INBOUND_MAIL_DOMAIN: ${INBOUND_MAIL_DOMAIN}
  136. INBOUND_MAIL_BUCKET: ${INBOUND_MAIL_BUCKET}
  137. INBOUND_MAIL_REGION: ${INBOUND_MAIL_REGION}
  138. INBOUND_MAIL_TOPIC_ARN: ${INBOUND_MAIL_TOPIC_ARN}
  139. PLATFORM_BACKUP_S3_ENDPOINT: ${PLATFORM_BACKUP_S3_ENDPOINT}
  140. PLATFORM_BACKUP_S3_BUCKET: ${PLATFORM_BACKUP_S3_BUCKET}
  141. PLATFORM_BACKUP_S3_REGION: ${PLATFORM_BACKUP_S3_REGION}
  142. PLATFORM_BACKUP_S3_ACCESS_KEY: ${PLATFORM_BACKUP_S3_ACCESS_KEY}
  143. PLATFORM_BACKUP_S3_SECRET_KEY: ${PLATFORM_BACKUP_S3_SECRET_KEY}
  144. PLATFORM_BACKUP_MASTER_KEY: ${PLATFORM_BACKUP_MASTER_KEY}
  145. PLATFORM_BACKUP_ALERT_EMAIL: ${PLATFORM_BACKUP_ALERT_EMAIL}
  146. BACKUP_MAX_DATA_FILE_MB: ${BACKUP_MAX_DATA_FILE_MB}
  147. NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
  148. GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
  149. GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
  150. GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}
  151. GOOGLE_AUTH_CLIENT_SECRET: ${GOOGLE_AUTH_CLIENT_SECRET}
  152. MICROSOFT_INTEGRATION_CLIENT_ID: ${MICROSOFT_INTEGRATION_CLIENT_ID}
  153. MICROSOFT_INTEGRATION_CLIENT_SECRET: ${MICROSOFT_INTEGRATION_CLIENT_SECRET}
  154. ZOOM_INTEGRATION_CLIENT_ID: ${ZOOM_INTEGRATION_CLIENT_ID}
  155. ZOOM_INTEGRATION_CLIENT_SECRET: ${ZOOM_INTEGRATION_CLIENT_SECRET}
  156. QUICKBOOKS_INTEGRATION_CLIENT_ID: ${QUICKBOOKS_INTEGRATION_CLIENT_ID}
  157. QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
  158. FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
  159. FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
  160. # Healthy once the server answers and reaches the database. The
  161. # deploy waits for it and puts the previous version back when it
  162. # never comes. Failures in start_period do not count, so a long
  163. # migration at start is not taken for a crash. nginx-proxy routes
  164. # by container, not by health, so this changes no traffic.
  165. healthcheck:
  166. test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/v1/health"]
  167. interval: 10s
  168. timeout: 10s
  169. retries: 3
  170. start_period: 30m
  171. networks:
  172. - proxy
  173. COMPOSE
  174. # Without the cloud token the new container would come up as a
  175. # self-hosted install: one workshop for everybody, branding back on.
  176. # Stop here, before anything is replaced.
  177. if [ -z "$TORQVOICE_CLOUD_TOKEN" ]; then
  178. echo "::error title=Cloud token missing::CLOUD_TORQVOICE_CLOUD_TOKEN is empty. Mint one for $APP_URL with scripts/mint-cloud-token.mjs in the torqvoice.com repo."
  179. exit 1
  180. fi
  181. # The link to torqvoice.com is what the subscription pages hang on. A
  182. # wrong secret or an origin the site does not list only shows up as a
  183. # missing page, so the deploy asks the site itself and says so.
  184. if [ -n "$TORQVOICE_SERVICE_SECRET" ]; then
  185. status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST https://torqvoice.com/api/app/subscription/ping \
  186. -H "Authorization: Bearer $TORQVOICE_SERVICE_SECRET" -H 'Content-Type: application/json' \
  187. -d "{\"appUrl\":\"$APP_URL\"}" || echo 000)
  188. if [ "$status" != "200" ]; then
  189. echo "::warning title=torqvoice.com link not confirmed::ping answered $status. Check TORQVOICE_SERVICE_SECRET on both sides and that $APP_URL is TORQVOICE_APP_URL or TORQVOICE_STAGING_APP_URL on torqvoice.com. The subscription page stays hidden until it is."
  190. else
  191. echo "torqvoice.com link confirmed for $APP_URL"
  192. fi
  193. else
  194. echo "::warning title=torqvoice.com link not configured::TORQVOICE_SERVICE_SECRET is empty, so plans cannot be bought from this deployment."
  195. fi
  196. - name: Ensure data directory permissions
  197. run: |
  198. DATA_DIR="${{ secrets.DATA_PATH }}/torqvoice_data"
  199. mkdir -p "$DATA_DIR"
  200. docker run --rm -v "$DATA_DIR":/data alpine chown -R 1001:1001 /data
  201. - name: Check free disk space
  202. run: |
  203. MIN_GB=5
  204. DOCKER_ROOT=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo /var/lib/docker)
  205. AVAIL_KB=$(df -Pk "$DOCKER_ROOT" | awk 'NR==2 {print $4}')
  206. AVAIL_GB=$(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}")
  207. echo "Free space on $DOCKER_ROOT: ${AVAIL_GB} GB (minimum ${MIN_GB} GB)"
  208. if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
  209. echo "::error::Not enough disk space to pull image: ${AVAIL_GB} GB free, need ${MIN_GB} GB. Aborting deploy."
  210. exit 1
  211. fi
  212. - name: Back up production database
  213. env:
  214. DATABASE_URL: ${{ secrets.CLOUD_DATABASE_URL }}
  215. run: |
  216. set -euo pipefail
  217. DB_USER=$(echo "$DATABASE_URL" | sed -E 's|^[^:]+://([^:]+):.*|\1|')
  218. DB_NAME=$(echo "$DATABASE_URL" | sed -E 's|.*/([^/?]+)(\?.*)?$|\1|')
  219. BACKUP_DIR="${{ secrets.DATA_PATH }}/db-backups/$DB_NAME"
  220. # The volume is root-owned and the runner is not root. Binding the
  221. # path into a container makes Docker create it, and the chown hands
  222. # it to the runner so the dump redirect below can write there.
  223. docker run --rm -v "$BACKUP_DIR":/b alpine chown "$(id -u):$(id -g)" /b
  224. # The Postgres data directory lives on this filesystem: filling it
  225. # takes the database down, not just the backup.
  226. MIN_GB=2
  227. AVAIL_KB=$(df -Pk "$BACKUP_DIR" | awk 'NR==2 {print $4}')
  228. if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
  229. echo "::error::Only $(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}") GB free where the database lives. Refusing to dump."
  230. exit 1
  231. fi
  232. DUMP="$BACKUP_DIR/${DB_NAME}-$(date +%Y%m%d-%H%M%S)-pre-${{ steps.tag.outputs.value }}.dump"
  233. echo "Dumping $DB_NAME to $DUMP"
  234. docker exec torqvoice-db pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc > "$DUMP"
  235. # A dump nobody can read is worse than no dump. No backup, no deploy.
  236. docker exec -i torqvoice-db pg_restore --list < "$DUMP" > /dev/null
  237. echo "Verified $(du -h "$DUMP" | cut -f1) backup"
  238. # Keep the 10 most recent pre-deploy dumps; hourly dumps in the same
  239. # folder belong to the backup cron and age out there.
  240. ls -t "$BACKUP_DIR"/*-pre-*.dump 2>/dev/null | tail -n +11 | xargs -r rm --
  241. # What to put back if the new version never gets healthy. By image ID,
  242. # since a tag can have moved by the time it is needed.
  243. - name: Note the running version
  244. id: previous
  245. run: |
  246. if docker inspect torqvoice-app > /dev/null 2>&1; then
  247. echo "image_id=$(docker inspect --format '{{.Image}}' torqvoice-app)" >> "$GITHUB_OUTPUT"
  248. echo "image=$(docker inspect --format '{{.Config.Image}}' torqvoice-app)" >> "$GITHUB_OUTPUT"
  249. echo "Running now: $(docker inspect --format '{{.Config.Image}}' torqvoice-app)"
  250. else
  251. echo "Nothing running yet, so there is nothing to roll back to."
  252. fi
  253. - name: Deploy
  254. run: |
  255. cd $HOME/torqvoice-deploy/prod
  256. APP_TAG=${{ steps.tag.outputs.value }} docker compose up -d --pull always
  257. - name: Health check, rolling back if it fails
  258. run: |
  259. cd "$HOME/torqvoice-deploy/prod"
  260. bash "$GITHUB_WORKSPACE/.github/scripts/roll-back-if-unhealthy.sh" \
  261. torqvoice-app \
  262. "${{ steps.tag.outputs.value }}" \
  263. "${{ steps.previous.outputs.image_id }}" \
  264. "${{ steps.previous.outputs.image }}" \
  265. prod
  266. - name: Prune old images and build cache
  267. run: docker image prune -f && docker builder prune -f