-
Notifications
You must be signed in to change notification settings - Fork 4
358 lines (341 loc) · 17.9 KB
/
Copy pathdeploy.yml
File metadata and controls
358 lines (341 loc) · 17.9 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
# .github/workflows/deploy.yml
name: Deploy to Cloudflare
# The pipeline that was missing.
#
# Production is a Cloudflare Worker (`hackhq`, serving hacking-hq.com through
# OpenNext). Nothing in this repository deployed it: there was no deploy
# workflow, so shipping meant somebody running `npm run deploy` from a laptop.
# Between 2026-08-18 and 2026-08-25 nobody did, and production sat on version
# 138 while six newer versions were uploaded and never promoted. (Those uploads
# were Cloudflare Workers Builds, which turned out to be connected after all -
# see "Why the deploy verifies itself" below.)
#
# That matters more than a week of staleness, because of how this site is built:
# listing data is frozen into the bundle at build time (web/README.md ->
# Render model). Hourly ISR re-derives *deadline* state from the deployed
# snapshot, which is why three of the four listings auto-closed during the freeze
# corrected themselves and one - archived for "event ended" rather than a passed
# deadline - stayed wrongly open. A newly *added* listing has no such fallback:
# without a rebuild it never appears at all. The listing bots therefore only
# reach users through this workflow.
#
# Why a schedule and not just `on: push`:
#
# Every automated edit to listings.json is pushed to main with the default
# GITHUB_TOKEN, and GitHub deliberately starts no workflow run for such a
# push. The push trigger below fires for human commits only. Without the
# sweep, exactly the automated listing updates this workflow exists to ship
# would be the ones it never saw - the same gap sync_supabase.yml documents
# and test_workflows.py pins down.
#
# Why `workflow_run` and not only the schedule:
#
# GitHub's schedule is best-effort. In practice the */30 sweep fired every
# 4-6 hours here (2026-08-28..09-01: 23 runs in 4.5 days), so a bot commit at
# 17:40 UTC shipped at 19:15. The listing bots' own runs are not throttled,
# and a workflow_run event fires the moment one of them completes - whoever
# pushed the commit. That is now the primary trigger for bot-written
# listings; the schedule stays as a backstop for anything that slips past it
# (a bot run cancelled mid-push, a manual tag reset).
#
# Every workflow that pushes to main must be listed below, and
# test_workflows.py fails if one is missing.
#
# Why the deploy verifies itself:
#
# A second pipeline was found shipping this Worker (2026-09-01): Cloudflare
# Workers Builds had been connected on 2026-08-21 with a *development* Clerk
# publishable key as its build variable, and it built every push to main -
# bot pushes included - about two minutes after the commit. Each of those
# builds bounced every first visit through a dev-instance handshake and
# broke sign-in until this workflow's next sweep overwrote it. Disabling it
# is a one-time dashboard step (web/README.md -> Deployment -> Workers
# Builds); until then the verification below is what keeps that build from
# being recorded as shipped. Either way the lesson stands: "the upload
# succeeded" is not "this commit is live". The build publishes the commit it
# was made from at /site-data/build.json, and the deploy is only recorded as
# shipped once the public site serves that sha and answers a browser request
# with 200 rather than a Clerk handshake.
#
# This replaces mirror_production_branch.yml, which force-pushed main onto a
# `vercel` branch for a Vercel Git integration. Production is Cloudflare; that
# mirror shipped nothing.
on:
# Human pushes and merged PRs deploy immediately.
push:
branches: [main]
paths:
- 'web/**'
# The data the build freezes into the bundle. prepare-repo-data.mjs copies
# these three into web/lib/generated/ and the loaders import them.
- '.github/scripts/listings.json'
- '.github/scripts/geocodes.json'
- 'README.md'
# copy-repo-assets.mjs copies these into web/public/repo-assets/.
- 'assets/**'
- '.github/workflows/deploy.yml'
# Bot-written commits. A push made with the default GITHUB_TOKEN starts no
# workflow, but the completion of the workflow that made it is a first-class
# event. Names, not file names: this is how workflow_run addresses them.
workflow_run:
workflows:
- Update Closing Soon Badges
- Auto-Extract Opportunity
- Contribution Approved
- Update READMEs
- Update Gallery
- Gallery Photo Approved
types: [completed]
branches: [main]
schedule:
# Every 30 minutes, as a backstop. GitHub throttles this to every few hours
# under load, which is why it is no longer the path bot commits rely on. A
# sweep that finds nothing new costs one checkout and exits before
# installing anything.
- cron: '*/30 * * * *'
workflow_dispatch:
inputs:
force:
description: 'Deploy even if main has not moved since the last deploy'
type: boolean
default: false
permissions:
# Needed only to move the `production` tag, which is how this workflow
# remembers what it last shipped.
contents: write
concurrency:
# Queue, never cancel. Cancelling between `opennextjs-cloudflare build` and
# the upload can leave a half-built .open-next directory behind; cancelling
# after the upload but before the tag move makes the next run redeploy the
# same commit. Neither is harmful, both are confusing, and the job is capped
# at 20 minutes so a queue cannot build up.
group: deploy-cloudflare
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
# Full history and tags: the gate below reads refs/tags/production.
fetch-depth: 0
# Always the tip of main. For workflow_run this is the default anyway
# (github.sha is the last commit on the default branch), but say so:
# a deploy of the bot's pre-push checkout would ship the wrong tree.
ref: main
# Deciding "has anything changed" from the git tag rather than from a
# file diff, because the question is not "did these paths change" but
# "is what is live already this commit". Only a successful deploy moves
# the tag, so a failed run leaves the work pending for the next sweep
# instead of marking it shipped.
- name: Has main moved since the last successful deploy?
id: gate
env:
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
HAVE_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
run: |
set -euo pipefail
head_sha=$(git rev-parse HEAD)
echo "head=$head_sha" >> "$GITHUB_OUTPUT"
# Skip loudly rather than fail. This workflow lands before the token
# does, and the schedule runs every 30 minutes - failing on each of
# those would be 48 red runs a day and a mailbox full of alerts, which
# trains everyone to ignore this workflow before it has ever worked.
# A warning annotation is visible in the Actions UI without that.
if [ "$HAVE_TOKEN" != "true" ]; then
echo "::warning::CLOUDFLARE_API_TOKEN is not set, so nothing was deployed. \
Add it under Settings > Secrets and variables > Actions to turn on automated deploys. \
Until then production only changes when someone runs 'npm run deploy' by hand."
echo "deploy=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$FORCED" = "true" ]; then
echo "forced dispatch - deploying $head_sha"
echo "deploy=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if deployed=$(git rev-parse -q --verify refs/tags/production 2>/dev/null); then
deployed=$(git rev-parse "${deployed}^{commit}")
if [ "$deployed" = "$head_sha" ]; then
echo "production is already $head_sha - nothing to deploy"
echo "deploy=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "production is $deployed, main is $head_sha - deploying"
else
echo "no production tag yet - deploying $head_sha and creating it"
fi
echo "deploy=true" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
if: steps.gate.outputs.deploy == 'true'
with:
node-version: '24'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
if: steps.gate.outputs.deploy == 'true'
working-directory: web
run: npm ci
# The build-time preflight cannot see these: they live on the Worker, not
# in the build environment. Checked before the upload rather than after,
# because the failure mode is silence — none of these missing produces an
# error at runtime, only a quietly degraded site.
#
# NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is the trap, and it is already sprung.
# Next inlines it for the browser but *not* into the server bundle, so
# middleware.ts reads it from process.env at request time. It was a Worker
# secret on the live version 138 and is absent from the Worker today.
# Deploying without it makes isClerkConfigured() return false, which turns
# sign-in off and un-gates /my instead of failing.
- name: Verify the Worker's runtime secrets
if: steps.gate.outputs.deploy == 'true'
working-directory: web
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
npx wrangler secret list --format json > secrets.json
node --input-type=module -e '
import { readFileSync } from "node:fs";
const have = new Set(
JSON.parse(readFileSync("secrets.json", "utf8")).map((s) => s.name),
);
const required = [
["CLERK_SECRET_KEY", "sign-in stops working"],
["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "middleware treats Clerk as unconfigured: sign-in switches off and /my stops being gated"],
["SUPABASE_URL", "the tracker silently falls back to browser-local storage"],
];
const missing = required.filter(([n]) => !have.has(n));
if (!have.has("SUPABASE_ANON_KEY") && !have.has("SUPABASE_SERVICE_ROLE_KEY")) {
missing.push([
"SUPABASE_ANON_KEY or SUPABASE_SERVICE_ROLE_KEY",
"the tracker silently falls back to browser-local storage",
]);
}
if (missing.length === 0) {
console.log("[secrets] all runtime secrets present: " + [...have].sort().join(", "));
process.exit(0);
}
for (const [name, effect] of missing) {
console.log(`::error::Worker secret ${name} is missing - ${effect}`);
}
console.log(
"\nSet each with: npx wrangler secret put <NAME> (run from web/)\n" +
"A deploy never overwrites or deletes a secret, so this is a one-time step.",
);
process.exit(1);
'
rm -f secrets.json
# Build and upload in one step, because `npm run deploy` is also what a
# maintainer runs by hand — keeping CI on a different command is how the
# two quietly diverge. `predeploy` runs preflight-deploy.mjs first, which
# fails the job if any NEXT_PUBLIC_* value below is missing or is a
# development credential.
#
# Every NEXT_PUBLIC_* here is build-time: Next inlines it into the bundle.
# None of them is a Worker variable, and none should be set in the
# dashboard — see the comment block in web/wrangler.jsonc.
#
# The server-only secrets (CLERK_SECRET_KEY, SUPABASE_URL,
# SUPABASE_SERVICE_ROLE_KEY) are deliberately absent: they already live on
# the Worker as secrets, and a deploy never overwrites or deletes those.
- name: Build and deploy
if: steps.gate.outputs.deploy == 'true'
working-directory: web
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
NEXT_PUBLIC_MAPBOX_TOKEN: ${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN: ${{ secrets.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN }}
NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
run: npm run deploy
# The upload succeeding is not the same as this commit being live. Ask the
# public site which commit it is serving (prepare-repo-data.mjs writes it
# to /site-data/build.json at build time) and how it answers a browser.
# Fails the job if either is wrong, which leaves the production tag where
# it was so the next trigger tries again - the failure mode this guards
# against is a different build winning the race, and a quietly moved tag
# would call that shipped.
- name: Verify the public site serves this commit
if: steps.gate.outputs.deploy == 'true'
env:
HEAD_SHA: ${{ steps.gate.outputs.head }}
SITE_URL: https://hacking-hq.com
run: |
set -euo pipefail
# This build's own record of itself, written by prepare-repo-data.mjs
# during the build above. Compared whole, not by sha: a competing
# pipeline's build of the same commit carries the same sha, and on
# 2026-09-02 a forced redeploy passed a sha-only check against the
# very build it was replacing, then failed the browser probe on it.
# builtAt makes the record unique per build.
expected=$(cat web/public/site-data/build.json)
echo "expected: $expected"
live=""
for attempt in 1 2 3 4 5 6 7 8; do
live=$(curl -sS --max-time 20 -H 'cache-control: no-cache' \
"$SITE_URL/site-data/build.json?deploy=$HEAD_SHA" || true)
if [ "$live" = "$expected" ]; then break; fi
echo "attempt $attempt: site serves '${live:-nothing}', waiting for this build"
sleep 15
done
if [ "$live" != "$expected" ]; then
echo "::error::$SITE_URL is serving ${live:-no build.json} two minutes after deploying $HEAD_SHA (expected $expected). \
Another pipeline may be deploying this Worker (web/README.md -> Deployment), or the upload did not go live."
exit 1
fi
echo "site serves this build ($HEAD_SHA)"
# A browser-shaped request must get the page, not a redirect into a
# Clerk development instance (a build made with a pk_test_ key).
status=$(curl -sS -o /dev/null --max-time 20 -w '%{http_code}' \
-H 'accept: text/html,application/xhtml+xml' \
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/140.0 Safari/537.36 hackhq-deploy-check' \
"$SITE_URL/")
if [ "$status" != "200" ]; then
location=$(curl -sS -o /dev/null --max-time 20 -w '%{redirect_url}' \
-H 'accept: text/html,application/xhtml+xml' \
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/140.0 Safari/537.36 hackhq-deploy-check' \
"$SITE_URL/")
case "$location" in
*clerk.accounts.dev*)
echo "::error::$SITE_URL/ redirects browsers to a Clerk DEVELOPMENT instance. \
The build was made with a pk_test_ publishable key; the preflight should have refused it." ;;
*)
echo "::error::$SITE_URL/ answered HTTP $status to a browser request (redirect: '${location:-none}'), expected 200." ;;
esac
exit 1
fi
echo "visitor probe: 200"
# Let a competing deploy land, then look again. On 2026-09-02 this
# workflow's build passed both checks above and was replaced 55
# seconds later by Cloudflare Workers Builds' build of the same commit
# (same sha, different builtAt, Clerk development key). A build that
# changes underneath us within a couple of minutes is that race, and
# the tag must not record this run as what is live.
sleep 120
second=$(curl -sS --max-time 20 -H 'cache-control: no-cache' "$SITE_URL/site-data/build.json?settle=$HEAD_SHA" || true)
if [ "$second" != "$expected" ]; then
echo "::error::The live build changed within two minutes of this deploy (now: ${second:-nothing}, expected: $expected). Another pipeline is deploying this Worker - web/README.md -> Deployment -> Workers Builds."
exit 1
fi
status=$(curl -sS -o /dev/null --max-time 20 -w '%{http_code}' -H 'accept: text/html,application/xhtml+xml' -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/140.0 Safari/537.36 hackhq-deploy-check' "$SITE_URL/")
if [ "$status" != "200" ]; then
echo "::error::$SITE_URL/ answered HTTP $status to a browser request two minutes after deploying; the build was replaced or is misconfigured."
exit 1
fi
echo "settled: still this build, visitor probe 200"
# Only after a green, verified deploy. The tag is this workflow's memory
# of what is live, and the scheduled sweep reads it — writing it on
# failure would mark unshipped work as shipped and skip it forever.
- name: Record what was deployed
if: steps.gate.outputs.deploy == 'true'
env:
HEAD_SHA: ${{ steps.gate.outputs.head }}
run: |
set -euo pipefail
git tag -f production "$HEAD_SHA"
git push --force origin refs/tags/production:refs/tags/production
echo "production -> $HEAD_SHA"