-
-
Notifications
You must be signed in to change notification settings - Fork 282
239 lines (216 loc) · 10.1 KB
/
Copy pathdeploy-cloudflare.yml
File metadata and controls
239 lines (216 loc) · 10.1 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
name: Deploy to Cloudflare Workers
# Triggered by the top-level Deploy dispatcher (.github/workflows/deploy.yml)
# when CF secrets are configured. Manual runs via the Actions UI also
# supported.
on:
workflow_call:
workflow_dispatch:
inputs:
version:
description: 'Release tag to deploy (e.g. v2.1.0). Leave empty for latest.'
required: false
# Prevent overlapping deployments.
concurrency:
group: deploy-cloudflare
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Disable upstream-only workflows
env:
GH_TOKEN: ${{ github.token }}
run: |
for workflow in ci.yml release.yml; do
gh api -X PUT "repos/${{ github.repository }}/actions/workflows/$workflow/disable" 2>/dev/null && \
echo "Disabled $workflow" || echo "$workflow already disabled"
done
- name: Check required secrets
env:
HAS_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
HAS_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID != '' }}
run: |
if [ "$HAS_TOKEN" != "true" ] || [ "$HAS_ACCOUNT" != "true" ]; then
echo "::error::Missing required secrets. Go to Settings → Secrets and variables → Actions and add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID."
exit 1
fi
- name: Resolve release tag
id: release
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ -n "$INPUT_VERSION" ]; then
TAG="$INPUT_VERSION"
else
TAG=$(gh api repos/saltbo/zpan/releases/latest --jq '.tag_name')
fi
if [ -z "$TAG" ]; then
echo "::error::No release found in saltbo/zpan"
exit 1
fi
echo "version=$TAG" >> "$GITHUB_OUTPUT"
echo "### 🚀 Deploying $TAG" >> "$GITHUB_STEP_SUMMARY"
- uses: actions/checkout@v6
with:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Ensure D1 database exists
id: d1
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
DB_ID=$(pnpm exec wrangler d1 list --json | jq -r '.[] | select(.name == "zpan-db") | .uuid')
if [ -z "$DB_ID" ]; then
# wrangler d1 create does not support --json; parse the TOML snippet it prints.
DB_ID=$(pnpm exec wrangler d1 create zpan-db | awk -F'"' '/database_id/{print $2; exit}')
echo "Created D1 database: $DB_ID"
else
echo "Reusing D1 database: $DB_ID"
fi
if [ -z "$DB_ID" ]; then
echo "::error::Failed to obtain D1 database UUID"
exit 1
fi
echo "id=$DB_ID" >> "$GITHUB_OUTPUT"
- name: Ensure Queue exists
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
OUTPUT=$(pnpm exec wrangler queues create zpan-archive-jobs 2>&1) || true
if echo "$OUTPUT" | grep -q "already taken"; then
echo "Queue already exists, skipping"
elif echo "$OUTPUT" | grep -q "ERROR"; then
echo "$OUTPUT"
exit 1
else
echo "Created queue: zpan-archive-jobs"
fi
# Avatars are self-hosted in R2 on Workers (the PUBLIC_IMAGES binding). Provision the
# bucket so the binding resolves, expose its managed public URL, and pin it to
# PUBLIC_IMAGES_URL so prod serves avatars straight from R2 (zero Worker egress);
# without the secret the app falls back to its own /api/avatar-blobs route.
- name: Ensure R2 public-images bucket exists
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
EXISTS=$(curl -sf \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets" \
-H "Authorization: Bearer $CF_API_TOKEN" \
| jq -r '.result.buckets[]? | select(.name == "zpan-public-images") | .name')
if [ -z "$EXISTS" ]; then
curl -sf -X POST \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "zpan-public-images"}' > /dev/null
echo "Created R2 bucket: zpan-public-images"
else
echo "Reusing R2 bucket: zpan-public-images"
fi
- name: Enable R2 managed public URL + capture
id: r2
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
# Idempotent: PUT enabled=true — CF returns the same pub-<hash>.r2.dev
# on every call once enabled.
curl -sf -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets/zpan-public-images/domains/managed" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": true}' > /dev/null
DOMAIN=$(curl -sf \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets/zpan-public-images/domains/managed" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.domain')
if [ -z "$DOMAIN" ] || [ "$DOMAIN" = "null" ]; then
echo "::error::Failed to retrieve R2 managed public domain. Make sure CLOUDFLARE_API_TOKEN has 'R2 Storage: Edit' scope."
exit 1
fi
echo "url=https://$DOMAIN" >> "$GITHUB_OUTPUT"
echo "R2 public URL: https://$DOMAIN"
- name: Set PUBLIC_IMAGES_URL (always upsert — R2 domain is stable)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
echo "${{ steps.r2.outputs.url }}" | pnpm exec wrangler secret put PUBLIC_IMAGES_URL
echo "Set PUBLIC_IMAGES_URL = ${{ steps.r2.outputs.url }}"
- name: Patch wrangler.toml with D1 database ID
run: sed -i "s/database_id = \"[^\"]*\"/database_id = \"${{ steps.d1.outputs.id }}\"/" wrangler.toml
- name: Set BETTER_AUTH_SECRET (first deploy only)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
run: |
EXISTS=$(pnpm exec wrangler secret list --format json | jq -r '.[] | select(.name == "BETTER_AUTH_SECRET") | .name')
if [ -n "$USER_SECRET" ]; then
echo "$USER_SECRET" | pnpm exec wrangler secret put BETTER_AUTH_SECRET
echo "Set BETTER_AUTH_SECRET from GitHub secret"
elif [ -z "$EXISTS" ]; then
openssl rand -base64 32 | pnpm exec wrangler secret put BETTER_AUTH_SECRET
echo "Auto-generated BETTER_AUTH_SECRET"
else
echo "BETTER_AUTH_SECRET already set, skipping"
fi
# vite build runs the @cloudflare/vite-plugin, which writes the deploy
# config (dist/zpan/wrangler.json) that the subsequent wrangler deploy
# consumes: it resolves the assets directory to dist/client and inlines
# build-time defines. Must run after the D1 id patch so the generated
# config carries the real database_id. ZPAN_APP_VERSION sidesteps a git
# describe on the shallow tag checkout.
- name: Build
env:
ZPAN_APP_VERSION: ${{ steps.release.outputs.version }}
ZPAN_APP_COMMIT: ${{ github.sha }}
run: pnpm build
- name: Deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm run deploy
- name: Clear WebDAV domain readiness
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
pnpm exec wrangler d1 execute zpan-db --remote --command \
"INSERT INTO system_options (key, value) VALUES ('webdav_verified_origin', ''), ('webdav_verified_at', ''), ('webdav_verification_error', '') ON CONFLICT(key) DO UPDATE SET value = excluded.value"
- name: Reconcile fixed WebDAV domain
id: webdav
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
OUTPUT=$(node scripts/sync-cloudflare-webdav.mjs)
echo "$OUTPUT"
printf '%s\n' "$OUTPUT" >> "$GITHUB_STEP_SUMMARY"
- name: Record WebDAV domain readiness
if: steps.webdav.outputs.origin != ''
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
WEBDAV_ORIGIN: ${{ steps.webdav.outputs.origin }}
run: |
if [[ ! "$WEBDAV_ORIGIN" =~ ^https://[A-Za-z0-9.-]+$ ]]; then
echo "::error::Invalid WebDAV origin returned by deployment sync"
exit 1
fi
VERIFIED_AT=$(date -u +'%Y-%m-%dT%H:%M:%S.000Z')
pnpm exec wrangler d1 execute zpan-db --remote --command \
"INSERT INTO system_options (key, value) VALUES ('webdav_verified_origin', '$WEBDAV_ORIGIN'), ('webdav_verified_at', '$VERIFIED_AT'), ('webdav_verification_error', '') ON CONFLICT(key) DO UPDATE SET value = excluded.value"
echo "WebDAV readiness recorded for $WEBDAV_ORIGIN" >> "$GITHUB_STEP_SUMMARY"