Repository navigation
319 lines (278 loc) · 13.3 KB
/
Copy pathdeploy.yml
File metadata and controls
319 lines (278 loc) · 13.3 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
# ABOUTME: GitHub Actions workflow for deploying to Fastly (primary) and Cloudflare Pages (backup)
# ABOUTME: Triggers on push to main, or manual dispatch
name: Deploy to Production
on:
push:
branches:
- main
# Allow manually re-running a clean production deploy of the current main
# through the approved workflow (build -> publish -> purge -> verify) instead
# of re-running a past run.
workflow_dispatch:
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
guard-main-ref:
runs-on: ubuntu-24.04
steps:
- name: Verify production deploy ref
run: |
test "${{ github.ref }}" = "refs/heads/main" || {
echo "Production deploys must run from refs/heads/main, got ${{ github.ref }}" >&2
exit 1
}
build:
needs: guard-main-ref
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
env:
VITE_PRODUCT_ANALYTICS_ENABLED: 'false'
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1
include-hidden-files: true
deploy-fastly:
needs: build
runs-on: ubuntu-24.04
# Ceiling so a wedged step (e.g. the bundle verify polling a hung origin)
# fails loudly instead of holding the deploy-production concurrency lock for
# hours. Set above the verify's own worst case (~6 min of sleeps + up to two
# 10s fetches per attempt) so the verifier's descriptive failure surfaces
# before this generic timeout would.
timeout-minutes: 25
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
# Installs the compute-js workspace deps, including @fastly/cli, pinned
# via package-lock. The Fastly steps below invoke it as
# `npx --no-install fastly` from working-directory: compute-js — not a
# global `npm install -g @fastly/cli` — so the deploy uses the
# lockfile-pinned version (reproducible, visible to dependency tooling,
# and the same CLI local dev uses). The steps MUST run from compute-js;
# from the repo root, npx would resolve a different @fastly/cli that
# compute-js-static-publish pulls in. Bump the pin deliberately in
# compute-js/package.json.
- name: Install Fastly compute dependencies
run: npm ci
# Assert which Fastly CLI npx actually resolves: its major must match the
# @fastly/cli pin in compute-js/package.json. This deliberately checks the
# resolved version rather than a node_modules path, because where npm
# places that binary is an accident of lockfile history, not a stable
# fact. compute-js pins ^16 while @fastly/compute-js-static-publish pins
# ^15; in this lock the 16.0.0 copy happens to sit under compute-js/ with
# 15.4.0 at the root, but resolving those same ranges in a clean tree
# hoists 16.0.0 to the root instead, and any lockfile regeneration can
# flip the layout and delete compute-js/node_modules/ outright. A path
# guard hard-fails on that flip even though npx still resolves the right
# CLI. This check passes on every layout and still fails on a wrong
# resolution (e.g. a root ^15 binary), on the major mismatch.
- name: Show pinned Fastly CLI version
working-directory: compute-js
run: |
set -euo pipefail
want="$(node -p "require('./package.json').devDependencies['@fastly/cli'].match(/\\d+/)[0]")"
version_output="$(npx --no-install fastly version)"
line="${version_output%%$'\n'*}"
got="$(printf '%s' "$line" | sed -n 's/.*v\([0-9][0-9]*\)\..*/\1/p')"
echo "$line (compute-js pins ^${want}.x)"
[ "$want" = "$got" ] || {
echo "::error::Fastly CLI major '${got:-unparseable}' resolved, but compute-js pins ^${want}.x; deploys would run the wrong binary."
exit 1
}
- name: Deploy to Fastly Compute (edge worker)
working-directory: compute-js
env:
FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }}
run: |
set -euo pipefail
for attempt in $(seq 1 5); do
if npx --no-install fastly compute publish --non-interactive --token="$FASTLY_API_TOKEN"; then
exit 0
fi
if [ "$attempt" -lt 5 ]; then
echo "Fastly compute publish failed on attempt $attempt/5; retrying in 45 seconds."
sleep 45
fi
done
echo "::error::Fastly compute publish failed after 5 attempts."
exit 1
- name: Publish static content to KV Store
working-directory: compute-js
env:
FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }}
run: |
set -euo pipefail
# publish-content (compute-js-static-publish, pinned via
# package-lock) retries per-file uploads but, on final failure, logs
# " ❌ Failed: <key> → <msg>" and still exits 0 (the error is swallowed).
# Capture the output and fail the step if any upload was dropped, so a
# partial publish can't go green. We match the stable ASCII "Failed:"
# rather than the emoji so a locale/encoding change can't silently
# disable this guard. See #426.
npx --no-install @fastly/compute-js-static-publish publish-content 2>&1 | tee /tmp/publish-content.log
if grep -qF 'Failed:' /tmp/publish-content.log; then
echo "::error::publish-content dropped one or more uploads (see 'Failed:' above)."
exit 1
fi
- name: Purge Fastly cache
working-directory: compute-js
env:
FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }}
run: |
set -euo pipefail
service_id="$(awk -F' = ' '/^service_id/ {gsub(/"/, "", $2); print $2}' fastly.toml)"
test -n "$service_id" || {
echo "Failed to read service_id from compute-js/fastly.toml" >&2
exit 1
}
npx --no-install fastly purge --all --service-id "$service_id" --token="$FASTLY_API_TOKEN"
- name: Verify live .well-known endpoints
run: |
set -euo pipefail
check_json_endpoint() {
url="$1"
for attempt in $(seq 1 12); do
headers="$(curl -sSI "$url" || true)"
printf '%s\n' "$headers"
status="$(printf '%s\n' "$headers" | awk 'tolower($1) ~ /^http/ {print $2; exit}')"
content_type="$(printf '%s\n' "$headers" | awk -F': ' 'tolower($1) == "content-type" {print tolower($2); exit}' | tr -d '\r')"
if [ "$status" = "200" ]; then
case "$content_type" in
application/json*|application/pkcs7-mime*)
return 0
;;
esac
fi
echo "Attempt $attempt failed for $url (status=${status:-missing} content-type=${content_type:-missing})"
if [ "$attempt" -lt 12 ]; then
sleep 10
fi
done
echo "Timed out waiting for $url to return 200 JSON" >&2
exit 1
}
check_json_endpoint "https://divine.video/.well-known/apple-app-site-association"
check_json_endpoint "https://www.divine.video/.well-known/apple-app-site-association"
check_json_endpoint "https://divine.video/.well-known/assetlinks.json"
check_json_endpoint "https://www.divine.video/.well-known/assetlinks.json"
# The well-known checks above only cover static app-link files, which rarely
# change — so a stale frontend bundle can sail through a green deploy. This
# asserts the live origins actually serve the entry bundle we just built AND
# that the referenced JS asset is reachable (not just the index.html
# pointer), waiting out Fastly KV eventual consistency within the budget and
# failing the deploy if it never converges. See #426.
- name: Verify live frontend bundle matches the build
run: node scripts/verify-live-bundle.mjs
deploy-cloudflare:
needs: build
runs-on: ubuntu-24.04
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to Cloudflare Pages (backup)
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=divine-web-direct-deploy --branch=main
# A failed production deploy currently only emails the one person who
# triggered it (easy to miss, and reads like routine flaky-CI noise). This
# surfaces it to the whole team in #divine-alerts AND probes the live apex
# so the alert states the real-world impact — is the site actually serving a
# page, or handing visitors a blank one? Fires only when a deploy job fails.
#
# Graceful by design: no-ops (logs and exits 0) when the
# SLACK_DIVINE_ALERTS_WEBHOOK secret isn't set, so it can land before the
# webhook exists, and a Slack post failure never masks the original deploy
# failure. Posts via an Incoming Webhook (single channel, no token/scopes).
notify-failure:
# List every upstream job so a failure anywhere in the run is detectable.
# When guard/build fails, the deploy jobs are *skipped* (not failed), and a
# bare `if: failure()` over skipped needs would not fire — so a broken build
# would produce no alert. always() lets this job run despite skipped needs;
# contains(needs.*.result, 'failure') fires on any failed ancestor while
# staying silent on an all-success (or cancelled) run.
needs: [guard-main-ref, build, deploy-fastly, deploy-cloudflare]
if: ${{ always() && contains(needs.*.result, 'failure') }}
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Probe live site and alert #divine-alerts
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_DIVINE_ALERTS_WEBHOOK }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
COMMIT_SHA: ${{ github.sha }}
# Empty on workflow_dispatch (no head_commit); handled below.
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
set -uo pipefail
if [ -z "${SLACK_WEBHOOK:-}" ]; then
echo "SLACK_DIVINE_ALERTS_WEBHOOK not set; skipping Slack notification."
exit 0
fi
# index.html is served cache-control: no-store, so this probe reflects
# what real visitors get right now. The outage class we care about is a
# 200 OK with an empty/short body (the bundle pointer resolved but the
# body isn't served), so we check status, size, AND the root element.
probe_url="https://divine.video/?ci-probe=${GITHUB_RUN_ID}"
: > /tmp/probe-body
status="$(curl -sS -o /tmp/probe-body -w '%{http_code}' --max-time 15 "$probe_url" 2>/dev/null || true)"
status="${status:-000}"
bytes="$(wc -c < /tmp/probe-body | tr -d ' ')"
if [ "$status" = "200" ] && [ "${bytes:-0}" -gt 100 ] && grep -q 'id="root"' /tmp/probe-body; then
site_line="🟡 Live site still appears to serve a real page (HTTP ${status}, ${bytes} bytes). The deploy failed but visitors may be unaffected — confirm before standing down."
else
site_line="🔴 Live site looks BROKEN (HTTP ${status}, ${bytes} bytes). Visitors may be seeing a blank page. Re-run the deploy or roll back."
fi
commit_short="${COMMIT_SHA:0:8}"
commit_subject="$(printf '%s' "${COMMIT_MSG:-(manual run)}" | head -n1)"
# Literal backticks are intentional (Slack `code` markdown); the format
# string must stay single-quoted, so no expansion is expected here.
# shellcheck disable=SC2016
text="$(printf '*Production deploy failed* — divine-web\n%s\n\nCommit `%s` — %s\n<%s|View the failed run> · confirm at https://divine.video' \
"$site_line" "$commit_short" "$commit_subject" "$RUN_URL")"
payload="$(jq -n --arg t "$text" '{text: $t}')"
if curl -sS -X POST -H 'Content-type: application/json' \
--max-time 15 --data "$payload" "$SLACK_WEBHOOK" >/dev/null; then
echo "Posted deploy-failure alert to #divine-alerts."
else
echo "WARNING: failed to POST to Slack webhook (deploy failure still stands)."
fi