forked from paperclipai/paperclip
-
Notifications
You must be signed in to change notification settings - Fork 0
383 lines (347 loc) · 14.5 KB
/
Copy pathdeploy-vultr.yml
File metadata and controls
383 lines (347 loc) · 14.5 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
name: Deploy Vultr
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: deploy-vultr-master
cancel-in-progress: false
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build UI
run: pnpm --filter @paperclipai/ui build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push server image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.vps
push: true
tags: |
ghcr.io/viraforge/paperclip:${{ github.sha }}
ghcr.io/viraforge/paperclip:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
COMMIT_SHA=${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
timeout-minutes: 30
env:
VULTR_HOST: ${{ secrets.VULTR_HOST }}
VULTR_USER: ${{ secrets.VULTR_USER }}
VULTR_TARGET: /opt/paperclip
VULTR_ENV_FILE: /opt/paperclip/.env
VULTR_RELEASE_ID: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
VULTR_RELEASE_DIR: /opt/paperclip/releases/${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
PAPERCLIP_SERVER_IMAGE: ghcr.io/viraforge/paperclip:${{ github.sha }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Deploy guard (clean tree + pinned commit)
run: |
set -euo pipefail
./scripts/deploy-guard.sh --require-ref "${GITHUB_SHA}" --allow-detached-head
- name: Configure SSH key
run: |
set -euo pipefail
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
printf '%s\n' "${{ secrets.VULTR_SSH_PRIVATE_KEY }}" > "$HOME/.ssh/id_ed25519"
chmod 600 "$HOME/.ssh/id_ed25519"
- name: Configure known_hosts
run: |
set -euo pipefail
printf '%s\n' "${{ secrets.VULTR_KNOWN_HOSTS }}" > "$HOME/.ssh/known_hosts"
chmod 644 "$HOME/.ssh/known_hosts"
- name: Configure SSH connection reuse
run: |
set -euo pipefail
mkdir -p "$HOME/.ssh/controlmasters"
chmod 700 "$HOME/.ssh/controlmasters"
cat > "$HOME/.ssh/config" <<EOF
Host vultr-deploy
HostName ${VULTR_HOST}
User ${VULTR_USER}
IdentityFile $HOME/.ssh/id_ed25519
BatchMode yes
StrictHostKeyChecking yes
UserKnownHostsFile $HOME/.ssh/known_hosts
ConnectTimeout 10
ConnectionAttempts 3
ControlMaster auto
ControlPersist 10m
ControlPath $HOME/.ssh/controlmasters/%C
EOF
chmod 600 "$HOME/.ssh/config"
- name: Verify SSH access
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
"echo SSH_OK && hostname && whoami"
- name: Prepare release directory on VPS
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
"mkdir -p '$VULTR_RELEASE_DIR'"
- name: Sync repository to VPS
run: |
set -euo pipefail
rsync -avz \
--timeout=30 \
--exclude .git \
--exclude node_modules \
--exclude data \
--exclude .worktrees \
--exclude ui/.vite \
-e "ssh -F $HOME/.ssh/config -o ConnectTimeout=10" \
./ \
"vultr-deploy:$VULTR_RELEASE_DIR/"
- name: Backup production database
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
'set -euo pipefail
ts=$(date +%Y%m%d%H%M%S)
mkdir -p /opt/paperclip/db-backups
docker exec -i paperclip-db-1 pg_dump -U paperclip -d paperclip -Fc > "/opt/paperclip/db-backups/pre-deploy-${ts}.dump"'
- name: Authenticate VPS to GHCR and pull server image
run: |
set -euo pipefail
printf '%s' "${{ secrets.GITHUB_TOKEN }}" | ssh -F "$HOME/.ssh/config" \
vultr-deploy \
"docker login ghcr.io -u '${{ github.actor }}' --password-stdin && \
docker pull '$PAPERCLIP_SERVER_IMAGE'"
- name: Run database migrations
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
"set -euo pipefail
PAPERCLIP_SERVER_IMAGE='$PAPERCLIP_SERVER_IMAGE' \
docker compose --project-name paperclip --env-file '$VULTR_ENV_FILE' \
-f '$VULTR_RELEASE_DIR/docker-compose.vps.yml' \
run --rm --no-deps server pnpm db:migrate"
- name: Recreate VPS server and edge containers
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
"set -euo pipefail
# Stop edge first to cleanly release host port 80 before recreating.
# Without this explicit stop, docker-proxy may still hold the port when
# the new edge container tries to bind it, causing 'address already in use'.
docker compose --project-name paperclip --env-file '$VULTR_ENV_FILE' \
-f '$VULTR_RELEASE_DIR/docker-compose.vps.yml' \
stop edge || true
PAPERCLIP_SERVER_IMAGE='$PAPERCLIP_SERVER_IMAGE' \
docker compose --project-name paperclip --env-file '$VULTR_ENV_FILE' \
-f '$VULTR_RELEASE_DIR/docker-compose.vps.yml' \
up -d --force-recreate --no-deps server edge"
- name: Assert image revision provenance
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
'set -euo pipefail
running_revision=$(docker inspect paperclip-server-1 --format '"'"'{{index .Config.Labels "org.opencontainers.image.revision"}}'"'"')
printf "RUNNING_REVISION=%s\n" "$running_revision"
if [ "$running_revision" != "${{ github.sha }}" ]; then
printf "MISMATCH: expected %s, got %s\n" "${{ github.sha }}" "$running_revision" >&2
exit 1
fi'
- name: Validate remote compose state
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
"set -euo pipefail
PAPERCLIP_SERVER_IMAGE='$PAPERCLIP_SERVER_IMAGE' \
docker compose --project-name paperclip --env-file '$VULTR_ENV_FILE' \
-f '$VULTR_RELEASE_DIR/docker-compose.vps.yml' ps"
- name: Validate fresh server container timestamp
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
'set -euo pipefail
started_at=$(docker inspect -f '"'"'{{.State.StartedAt}}'"'"' paperclip-server-1)
started_epoch=$(date -u -d "$started_at" +%s)
now_epoch=$(date -u +%s)
age_seconds=$((now_epoch - started_epoch))
printf "CONTAINER_STARTED_AT=%s\n" "$started_at"
printf "CONTAINER_AGE_SECONDS=%s\n" "$age_seconds"
if [ "$age_seconds" -lt 0 ] || [ "$age_seconds" -gt 900 ]; then
echo "Server container is not fresh after recreate" >&2
exit 1
fi'
- name: Wait for remote localhost health
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
'set -euo pipefail
for attempt in $(seq 1 12); do
if curl --connect-timeout 5 --max-time 10 -fsS http://localhost:3100/api/health >/dev/null; then
exit 0
fi
sleep 5
done
echo "Timed out waiting for remote localhost health" >&2
exit 1'
- name: Wait for external host health
run: |
set -euo pipefail
for attempt in $(seq 1 12); do
if curl --connect-timeout 5 --max-time 10 -fsS "http://$VULTR_HOST:3100/api/health" > /dev/null; then
exit 0
fi
sleep 5
done
echo "Timed out waiting for external host health" >&2
exit 1
- name: Wait for external port 80 edge health
run: |
set -euo pipefail
for attempt in $(seq 1 12); do
if curl --connect-timeout 5 --max-time 10 -fsS "http://$VULTR_HOST/api/health" > /dev/null; then
exit 0
fi
sleep 5
done
echo "Timed out waiting for http://$VULTR_HOST/api/health (nginx edge on :80). Open TCP 80 on the VPS firewall and ensure nothing else binds host port 80." >&2
exit 1
- name: Validate external static asset fetch
run: |
set -euo pipefail
root_html=$(curl --connect-timeout 5 --max-time 10 -fsS "http://$VULTR_HOST:3100/")
asset_path=$(ROOT_HTML="$root_html" python3 -c "import os, re, sys; match = re.search(r'''(?:src|href)=[\"\\'](?P<path>/assets/[^\"\\'?#]+(?:\\?[^\"\\' ]*)?)[\"\\']''', os.environ['ROOT_HTML']); print(match.group('path')) if match else sys.exit(1)") || {
echo "No static asset path found in root HTML" >&2
exit 1
}
curl --connect-timeout 5 --max-time 10 -fsS "http://$VULTR_HOST:3100$asset_path" > /dev/null
printf 'FETCHED_ASSET=%s\n' "$asset_path"
- name: Validate OpenCode runtime env
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
'set -euo pipefail
env_output=$(docker inspect paperclip-server-1 --format '"'"'{{range .Config.Env}}{{println .}}{{end}}'"'"')
if printf "%s\n" "$env_output" | grep -q "^PAPERCLIP_OPENCODE_COMMAND=/"; then
printf "FOUND:%s\n" "PAPERCLIP_OPENCODE_COMMAND"
else
printf "MISSING_OR_INVALID:%s\n" "PAPERCLIP_OPENCODE_COMMAND" >&2
exit 1
fi
required_nonempty_vars="OPENCODE_CONFIG_CONTENT ZAI_API_KEY MINIMAX_API_KEY BETTER_AUTH_SECRET"
for required_var in $required_nonempty_vars; do
if printf "%s\n" "$env_output" | grep -q "^${required_var}=."; then
printf "FOUND_NONEMPTY:%s\n" "$required_var"
continue
fi
printf "MISSING_OR_EMPTY:%s\n" "$required_var" >&2
exit 1
done
public_url=$(printf "%s\n" "$env_output" | sed -n "s/^PAPERCLIP_PUBLIC_URL=//p" | head -n 1)
if [ -z "$public_url" ]; then
printf "MISSING_OR_EMPTY:%s\n" "PAPERCLIP_PUBLIC_URL" >&2
exit 1
fi
case "$public_url" in
http://localhost*|https://localhost*|http://127.0.0.1*|https://127.0.0.1*)
printf "INVALID_LOCALHOST:%s=%s\n" "PAPERCLIP_PUBLIC_URL" "$public_url" >&2
exit 1
;;
esac
case "$public_url" in
http://*|https://*)
printf "FOUND_EXPLICIT:%s=%s\n" "PAPERCLIP_PUBLIC_URL" "$public_url"
;;
*)
printf "MISSING_OR_INVALID:%s=%s\n" "PAPERCLIP_PUBLIC_URL" "$public_url" >&2
exit 1
;;
esac
printf "PUBLIC_URL_VALID:%s\n" "$public_url"'
- name: Read recent startup logs
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
'set -euo pipefail
logs=$(docker logs --tail 200 paperclip-server-1 2>&1)
printf "%s\n" "$logs"
fatal_pattern="panic:|uncaught exception|unhandledpromiserejection|error: listen eaddrinuse|syntaxerror:|cannot find module|module_not_found|address already in use"
if printf "%s\n" "$logs" | grep -Eiq "$fatal_pattern"; then
echo "Detected fatal startup pattern in recent logs" >&2
exit 1
fi
success_pattern="started|listening|ready|server running|health|/api/health|GET .* 200"
if printf "%s\n" "$logs" | grep -Eiq "$success_pattern"; then
exit 0
fi
echo "Recent logs did not show a basic startup success signal" >&2
exit 1'
- name: Write current release and image pointers
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
"set -euo pipefail
if [ -f /opt/paperclip/current-image ]; then
cp /opt/paperclip/current-image /opt/paperclip/current-image-prev
fi
printf '%s' '$PAPERCLIP_SERVER_IMAGE' > /opt/paperclip/current-image.tmp
mv /opt/paperclip/current-image.tmp /opt/paperclip/current-image
printf '%s' '$VULTR_RELEASE_DIR' > /opt/paperclip/current-release.tmp
mv /opt/paperclip/current-release.tmp /opt/paperclip/current-release
printf 'CURRENT_RELEASE=%s\n' \"\$(cat /opt/paperclip/current-release)\"
printf 'CURRENT_IMAGE=%s\n' \"\$(cat /opt/paperclip/current-image)\""
- name: Cleanup old releases and Docker cache
run: |
set -euo pipefail
ssh -F "$HOME/.ssh/config" \
vultr-deploy \
'set -euo pipefail
releases_dir=/opt/paperclip/releases
backups_dir=/opt/paperclip/db-backups
mkdir -p "$releases_dir" "$backups_dir"
ls -1dt "$releases_dir"/* 2>/dev/null | awk "NR>5" | xargs -r rm -rf --
ls -1dt "$backups_dir"/pre-deploy-*.dump 2>/dev/null | awk "NR>10" | xargs -r rm -f --
prev_image=$(cat /opt/paperclip/current-image-prev 2>/dev/null || true)
if [ -n "$prev_image" ]; then
docker tag "$prev_image" paperclip-rollback:prev 2>/dev/null || true
fi
docker image prune -af || true
docker builder prune -af || true'