-
Notifications
You must be signed in to change notification settings - Fork 0
408 lines (377 loc) · 18.8 KB
/
Copy pathdeploy.yml
File metadata and controls
408 lines (377 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
name: Deploy to Production
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "production"
type: choice
options:
- production
- staging
no_cache:
description: "Skip buildx layer cache (use when cache is poisoned or you're migrating away from deleted files)"
required: false
default: false
type: boolean
# Note: removed automatic `push: tags: v*` trigger. v* tags are now
# consumed by release-desktop.yml (build + publish desktop installers)
# and release-extension.yml (Web Store upload) — they should not also
# silently kick off a production backend deploy. Use the manual
# workflow_dispatch above to deploy explicitly.
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ghcr.io/myfoxit/stept
# Serialise deploys per environment. A second manual dispatch while a
# deploy is in flight will queue rather than race the first. Production
# never cancels an in-progress deploy — interrupting a half-applied
# migration is worse than waiting.
concurrency:
group: deploy-${{ inputs.environment || 'production' }}
cancel-in-progress: false
jobs:
build-and-push:
name: Build & Push Images
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
# Build attestations + SBOM signing require id-token + attestations.
id-token: write
attestations: write
outputs:
tag: ${{ steps.version.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Determine version tag
id: version
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
else
echo "tag=sha-${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend (API)
uses: docker/build-push-action@v6
with:
context: .
file: ./api/Dockerfile
push: true
platforms: linux/amd64
provenance: mode=max
sbom: true
no-cache: ${{ inputs.no_cache == true }}
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
tags: |
${{ env.IMAGE_PREFIX }}-api:latest
${{ env.IMAGE_PREFIX }}-api:${{ steps.version.outputs.tag }}
- name: Build and push frontend
uses: docker/build-push-action@v6
with:
context: .
file: ./app/Dockerfile
push: true
platforms: linux/amd64
provenance: mode=max
sbom: true
no-cache: ${{ inputs.no_cache == true }}
cache-from: type=gha,scope=app
cache-to: type=gha,mode=max,scope=app
tags: |
${{ env.IMAGE_PREFIX }}-app:latest
${{ env.IMAGE_PREFIX }}-app:${{ steps.version.outputs.tag }}
- name: Build and push collab server
uses: docker/build-push-action@v6
with:
# Root context: collab bundles @stept/whiteboard from packages/.
context: .
file: ./collab/Dockerfile
push: true
platforms: linux/amd64
provenance: mode=max
sbom: true
no-cache: ${{ inputs.no_cache == true }}
cache-from: type=gha,scope=collab
cache-to: type=gha,mode=max,scope=collab
tags: |
${{ env.IMAGE_PREFIX }}-collab:latest
${{ env.IMAGE_PREFIX }}-collab:${{ steps.version.outputs.tag }}
- name: Build and push automation engine
uses: docker/build-push-action@v6
with:
context: .
file: ./automation/Dockerfile
push: true
platforms: linux/amd64
provenance: mode=max
sbom: true
no-cache: ${{ inputs.no_cache == true }}
cache-from: type=gha,scope=automation
cache-to: type=gha,mode=max,scope=automation
tags: |
${{ env.IMAGE_PREFIX }}-automation:latest
${{ env.IMAGE_PREFIX }}-automation:${{ steps.version.outputs.tag }}
deploy:
name: Deploy to Hetzner
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build-and-push
environment: ${{ inputs.environment || 'production' }}
steps:
- uses: actions/checkout@v4
- name: Copy deployment files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "docker-compose.prod.yml,Caddyfile"
target: ${{ secrets.DEPLOY_PATH || '/opt/stept' }}
overwrite: true
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
env:
IMAGE_TAG: ${{ needs.build-and-push.outputs.tag }}
DOMAIN: ${{ secrets.DOMAIN }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: IMAGE_TAG,DOMAIN
script: |
set -euo pipefail
DEPLOY_DIR="${{ secrets.DEPLOY_PATH || '/opt/stept' }}"
cd "$DEPLOY_DIR"
echo "=== Deploying $IMAGE_TAG ==="
# Login to GHCR
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u "${{ secrets.GHCR_USER }}" --password-stdin
# Write deployment env on every deploy so staging/prod stay reproducible.
# IMAGE_TAG is pinned here because compose's image: ${IMAGE_TAG:-latest}
# silently regresses to :latest during any manual `docker compose up`
# if not set — which overwrote staging to a 3-week-old image during
# a mid-deploy DB reset. Persist it.
cat > .env << ENVEOF
IMAGE_TAG=${IMAGE_TAG}
ENVEOF
cat >> .env << 'ENVEOF'
DOMAIN=${{ secrets.DOMAIN }}
STAGING_PROTECT=${{ secrets.STAGING_PROTECT || 'false' }}
BASIC_AUTH_USER=${{ secrets.BASIC_AUTH_USER }}
BASIC_AUTH_HASH=${{ secrets.BASIC_AUTH_HASH }}
POSTGRES_USER=${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB=${{ secrets.POSTGRES_DB || 'stept' }}
REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
JWT_SECRET=${{ secrets.JWT_SECRET }}
STEPT_ENCRYPTION_KEY=${{ secrets.STEPT_ENCRYPTION_KEY }}
# Shared secret authenticating internal backend<->collab API calls.
# docker-compose.prod.yml marks it required (${VAR:?...}), so compose
# refuses to start if it is unset. Added in security commit d0973cc;
# must be present in each deploy environment's secrets.
INTERNAL_API_SECRET=${{ secrets.INTERNAL_API_SECRET }}
# Shared secret authenticating backend<->automation internal calls
# (MCP browser tools proxy + docs-bridge import). Required by
# docker-compose.prod.yml — must exist in each environment's secrets.
AUTOMATION_INTERNAL_SECRET=${{ secrets.AUTOMATION_INTERNAL_SECRET }}
# Optional: enables AI selector-healing / title polish in the engine
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
FRONTEND_URL=${{ secrets.FRONTEND_URL }}
# Used by app/routers/install.py to fetch release manifests
# and proxy private-repo asset downloads via the GitHub API.
# Token name is intentionally generic ("GITHUB_TOKEN") because
# that's what the install router reads.
GITHUB_TOKEN=${{ secrets.STEPT_RELEASES_TOKEN }}
CORS_ORIGINS=${{ secrets.CORS_ORIGINS }}
ALLOWED_ORIGINS=${{ secrets.ALLOWED_ORIGINS }}
ENVIRONMENT=${{ inputs.environment || 'production' }}
# Platform-admin email allowlist (comma-separated). Owns
# workspace fallback LLM connection + SSO config until the
# users.role column lands in Phase 3 of the multi-user
# rework. Empty = no platform admins; gated endpoints 403
# for everyone (safe default — operators must opt in).
PLATFORM_ADMIN_EMAILS=${{ secrets.PLATFORM_ADMIN_EMAILS }}
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
# OAuth social login (Google / GitHub). The GitHub pair is
# read from OAUTH_-prefixed secrets because Actions forbids
# secret names starting with GITHUB_.
GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID=${{ secrets.OAUTH_GITHUB_CLIENT_ID }}
GITHUB_CLIENT_SECRET=${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }}
# Integration-catalog OAuth (Gmail / Google Calendar / Google
# Drive). The catalog reads per-integration {ID}_CLIENT_ID/SECRET
# env pairs; all three reuse the login Google client, which has
# the /api/v1/integrations/callback redirect URI registered.
GMAIL_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GMAIL_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
GOOGLE_CALENDAR_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CALENDAR_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
GOOGLE_DRIVE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_DRIVE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
# Slack / Microsoft OAuth apps. Each pair is optional: when a
# secret is unset the tile stays hidden instead of offering a
# connect button that can only answer "not configured".
SLACK_CLIENT_ID=${{ secrets.SLACK_CLIENT_ID }}
SLACK_CLIENT_SECRET=${{ secrets.SLACK_CLIENT_SECRET }}
MICROSOFT_TEAMS_CLIENT_ID=${{ secrets.MICROSOFT_CLIENT_ID }}
MICROSOFT_TEAMS_CLIENT_SECRET=${{ secrets.MICROSOFT_CLIENT_SECRET }}
OUTLOOK_MAIL_CLIENT_ID=${{ secrets.MICROSOFT_CLIENT_ID }}
OUTLOOK_MAIL_CLIENT_SECRET=${{ secrets.MICROSOFT_CLIENT_SECRET }}
OUTLOOK_CALENDAR_CLIENT_ID=${{ secrets.MICROSOFT_CLIENT_ID }}
OUTLOOK_CALENDAR_CLIENT_SECRET=${{ secrets.MICROSOFT_CLIENT_SECRET }}
# Integration + channel allowlists (match the Settings defaults;
# explicit here so an env-secret override stays possible).
STEPT_ENABLED_INTEGRATIONS=google_calendar,google_drive,github,jira,confluence,notion,slack,microsoft_teams,outlook_mail,outlook_calendar
STEPT_ENABLED_CHANNELS=slack,teams,discord,telegram
# Mail — EMAIL_BACKEND selects which provider is used (smtp|resend|sendgrid|ses|postmark|console)
EMAIL_BACKEND=${{ secrets.EMAIL_BACKEND }}
EMAIL_FROM=${{ secrets.EMAIL_FROM }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
SENDGRID_API_KEY=${{ secrets.SENDGRID_API_KEY }}
POSTMARK_SERVER_TOKEN=${{ secrets.POSTMARK_SERVER_TOKEN }}
SES_REGION=${{ secrets.SES_REGION }}
SMTP_HOST=${{ secrets.SMTP_HOST }}
SMTP_PORT=${{ secrets.SMTP_PORT }}
SMTP_USER=${{ secrets.SMTP_USER }}
SMTP_PASS=${{ secrets.SMTP_PASS }}
SMTP_FROM=${{ secrets.SMTP_FROM }}
# Storage — STORAGE_BACKEND selects which backend is used (local|s3|gcs|azure)
STORAGE_BACKEND=${{ secrets.STORAGE_BACKEND }}
S3_BUCKET=${{ secrets.S3_BUCKET }}
S3_REGION=${{ secrets.S3_REGION }}
S3_ENDPOINT_URL=${{ secrets.S3_ENDPOINT_URL }}
S3_ACCESS_KEY_ID=${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY=${{ secrets.S3_SECRET_ACCESS_KEY }}
S3_PREFIX=${{ secrets.S3_PREFIX }}
ENVEOF
# Pull images
docker compose -f docker-compose.prod.yml pull backend frontend collab automation
# Stop any leftover media-worker from previous deploys (video import removed)
docker compose -f docker-compose.prod.yml stop media-worker 2>/dev/null || true
docker compose -f docker-compose.prod.yml rm -f media-worker 2>/dev/null || true
# Bring up services
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Wait for backend health (needed so we can exec alembic in it).
# NOTE: the backend may start with a stale schema on the first
# boot if new migrations exist — /health still returns 200 because
# it doesn't touch the DB. We re-check functional health below.
echo "Waiting for backend process..."
for i in $(seq 1 30); do
if docker compose -f docker-compose.prod.yml exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" 2>/dev/null; then
echo "Backend process up."
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Backend did not become responsive"
docker compose -f docker-compose.prod.yml logs --tail=50 backend
exit 1
fi
sleep 3
done
# Run database migrations. A failure here FAILS the deploy.
# The previous version of this step "rescued" migration errors
# by stamping to head, which silently desynced alembic_version
# from the real schema. Cost us a bad deploy. Never again.
echo "Running migrations..."
docker compose -f docker-compose.prod.yml exec -T backend alembic upgrade head
# Restart backend so SQLAlchemy reloads the schema metadata.
# Without this, queries against newly-added columns fail until
# the next natural restart.
echo "Restarting backend to pick up new schema..."
docker compose -f docker-compose.prod.yml restart backend
for i in $(seq 1 30); do
if docker compose -f docker-compose.prod.yml exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" 2>/dev/null; then
echo "Backend healthy after migrations."
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Backend did not come back after migration restart"
docker compose -f docker-compose.prod.yml logs --tail=80 backend
exit 1
fi
sleep 3
done
# Reload Caddy with the new Caddyfile. `docker compose up -d`
# only restarts containers whose image/config changed; the
# bind-mounted Caddyfile changes are invisible to compose, so
# without an explicit reload the container keeps running with
# the previous config. Past failure mode: a routing fix landed
# in source but never took effect on the live proxy. `caddy
# reload` is graceful (keeps existing connections, swaps config
# atomically). If the reload fails the deploy fails.
echo "Reloading Caddy with new Caddyfile..."
if ! docker compose -f docker-compose.prod.yml exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile; then
echo "ERROR: caddy reload failed — Caddyfile is invalid or container is unhealthy"
docker compose -f docker-compose.prod.yml logs --tail=80 caddy
exit 1
fi
# Wait for Caddy TLS
echo "Checking HTTPS..."
for i in $(seq 1 15); do
if curl -sf "https://${DOMAIN:-localhost}/health" -o /dev/null 2>&1; then
echo "HTTPS live!"
break
fi
sleep 4
done
# Functional smoke test — the endpoint must touch the DB so we
# catch schema/migration mismatches the bare /health misses.
# /ready queries the DB; a 200 means migrations stuck.
echo "Smoke test: /ready (DB-backed)"
if ! curl -sf --max-time 10 "https://${DOMAIN:-localhost}/ready" -o /dev/null; then
echo "ERROR: /ready failed — app is up but DB connectivity/schema is wrong"
docker compose -f docker-compose.prod.yml logs --tail=80 backend
exit 1
fi
echo "Smoke test passed."
# Collab WS handshake gate. Asserts that wss://${DOMAIN}/collab
# returns HTTP 101 Switching Protocols. Without this, a Caddy
# routing regression silently breaks every editor session — the
# bare /collab path falls through to the SPA, the browser sees
# HTTP 200 (HTML) instead of 101, and closes with code 1006 in
# an infinite reconnect loop. Internal /ready and /health both
# stay green throughout; only end-to-end traffic notices.
#
# We capture the status code into a variable rather than
# piping into grep because a successful 101 upgrade leaves
# the WS connection open — curl reads no body and exits 28
# (timeout) at --max-time even though the upgrade succeeded.
# Under `set -o pipefail` (set at the top of this script),
# that exit 28 fails the pipeline regardless of grep's match.
# Suppressing curl's exit with `|| true` and asserting the
# captured code keeps the gate honest about what we actually
# care about: the response status, not the lifecycle of an
# open WebSocket.
echo "Smoke test: collab WS handshake"
COLLAB_HTTP_CODE=$(curl --http1.1 -s --max-time 8 \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
-o /dev/null \
-w '%{http_code}' \
"https://${DOMAIN:-localhost}/collab" 2>/dev/null || true)
if [ "$COLLAB_HTTP_CODE" != "101" ]; then
echo "ERROR: /collab handshake returned '$COLLAB_HTTP_CODE' instead of 101 — proxy is mis-routing the WebSocket upgrade"
docker compose -f docker-compose.prod.yml logs --tail=80 caddy
docker compose -f docker-compose.prod.yml logs --tail=80 collab
exit 1
fi
echo "Collab handshake passed."
# Cleanup
docker image prune -f
echo "=== Deploy complete: $(date) ==="
docker compose -f docker-compose.prod.yml ps