forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
322 lines (298 loc) · 15.9 KB
/
Copy pathpr-preview.yml
File metadata and controls
322 lines (298 loc) · 15.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
# Deploys a live, testable build of the app for every pull request, and posts
# the link as a PR comment - so a reviewer can try the actual change on a
# phone instead of reading a diff and imagining it.
#
# Previews go to Cloudflare Pages, not to the `gh-pages` branch the production
# site lives on. They used to, under /OurHike/pr-preview/pr-<n>/, and the
# reason they moved is that five to ten pull requests are open and building at
# once here as a matter of course. Every one of those previews was a write to
# the same git ref, git admits one writer per push, and the action doing the
# pushing retried three times with no backoff before giving up - so previews
# failed for no reason other than each other being busy.
#
# Cloudflare Pages does not have that shape of problem. A preview is an upload
# to its own deployment rather than a commit on a shared branch, there is no
# ordering between two of them, and the free plan places no limit on how many
# preview deployments a project has. Ten at once is not a busy day, it is ten
# uploads.
#
# The build still happens here rather than on Cloudflare's builders. That is
# what keeps the free plan's build quota (500 a month, one at a time) out of
# the picture entirely: `wrangler pages deploy` uploads an already-built
# directory, so nothing queues behind anyone else's build.
#
# SETUP: needs a Pages project and an API token - LAUNCH_CHECKLIST.md 3a.
# Until those exist this workflow says so and skips, rather than failing every
# pull request with a wrangler error.
name: PR preview
on:
pull_request:
branches: [main]
types: [opened, reopened, synchronize, closed]
# Per pull request, and cancelling: only the newest commit's preview is worth
# having, so superseding an in-flight build is right. Deliberately NOT a group
# shared across pull requests - GitHub holds one pending run per group and
# cancels the rest, so a shared group would drop most of ten queued previews
# rather than taking turns.
concurrency:
group: pr-preview-${{ github.event.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
preview:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# A pull request from a fork gets no secrets, so there is no token to
# deploy with and nothing this job could do but fail. Deciding that here,
# once, keeps every step below from having to be written as though its
# inputs might be empty.
- name: Check whether this run can deploy
id: can
env:
API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }}
# Through env, not interpolated into the script body, for the reason
# spelled out in pages.yml: `${{ }}` is substituted as literal text
# before bash sees the line, so a value carrying a quote would be
# parsed as script rather than as data.
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
THIS_REPO: ${{ github.repository }}
run: |
if [ -z "$API_TOKEN" ] || [ -z "$ACCOUNT_ID" ] || [ -z "$PROJECT" ]; then
if [ "$HEAD_REPO" != "$THIS_REPO" ]; then
echo "::notice::A pull request from a fork gets no secrets, so no preview is deployed for one. The change still gets its full test run."
else
echo "::warning::No Cloudflare Pages project is configured, so this pull request gets no preview. See LAUNCH_CHECKLIST.md 3a."
fi
echo "deploy=false" >> "$GITHUB_OUTPUT"
else
echo "deploy=true" >> "$GITHUB_OUTPUT"
fi
# The alias that gives this pull request a stable preview URL across
# every push to it. Cloudflare builds the hostname from it, so it has to
# survive being a DNS label: `pr-<n>` is short, lower case and cannot
# collide with another pull request's.
#
# The URL here is worked out rather than observed, so it is the second
# choice of the two the comment below picks between: a deploy reports the
# alias URL it actually created, and that is the one to link to when it
# exists, because it cannot disagree with where the upload went. This one
# is what remains when there was no deploy in this run at all - the
# comment posted when a pull request closes - and it assumes the project
# subdomain matches the project name, which is how Pages names them.
- name: Name this preview
id: preview
env:
NUMBER: ${{ github.event.number }}
PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }}
run: |
{
echo "alias=pr-$NUMBER"
echo "url=https://pr-$NUMBER.$PROJECT.pages.dev"
} >> "$GITHUB_OUTPUT"
# On the closed path too, not just the build path: the teardown below is
# a repo-local action, which the runner resolves out of the workspace at
# step time. With nothing checked out it cannot even be found - so every
# close failed the cleanup on its way to a comment claiming the previews
# had been removed (#643). Everything the build needs stays gated on
# not-closed below; the close needs the repository and nothing else.
- uses: actions/checkout@v4
if: steps.can.outputs.deploy == 'true'
- uses: actions/setup-node@v4
if: steps.can.outputs.deploy == 'true' && github.event.action != 'closed'
with:
node-version: '24'
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install
if: steps.can.outputs.deploy == 'true' && github.event.action != 'closed'
working-directory: client
run: npm ci
# Same data source as the production build (see pages.yml) - a preview
# that can't download map data isn't much of a testable version.
- name: Resolve the data source
if: steps.can.outputs.deploy == 'true' && github.event.action != 'closed'
id: data
env:
FROM_VAR: ${{ vars.DATA_BASE_URL }}
FROM_SECRET: ${{ secrets.DATA_BASE_URL }}
run: |
URL="${FROM_VAR:-$FROM_SECRET}"
echo "url=$URL" >> "$GITHUB_OUTPUT"
# Same Supabase project as production (see pages.yml) - a preview where
# signing in is the thing under review is not testable without it.
#
# Each pull request previews from its own hostname, and the client
# redirects back to wherever it was served from, so the Supabase
# project's allowed redirect list needs a wildcard covering them -
# `https://*.<project>.pages.dev/**`. Without it a provider round trip
# from a preview ends in a redirect mismatch, and adding one entry per
# pull request by hand is not a plan. See LAUNCH_CHECKLIST.md 4.3b.
- name: Resolve the Supabase project
if: steps.can.outputs.deploy == 'true' && github.event.action != 'closed'
id: supabase
env:
URL_FROM_VAR: ${{ vars.SUPABASE_URL }}
URL_FROM_SECRET: ${{ secrets.SUPABASE_URL }}
KEY_FROM_VAR: ${{ vars.SUPABASE_ANON_KEY }}
KEY_FROM_SECRET: ${{ secrets.SUPABASE_ANON_KEY }}
run: |
URL="${URL_FROM_VAR:-$URL_FROM_SECRET}"
KEY="${KEY_FROM_VAR:-$KEY_FROM_SECRET}"
# Say so in the log. Without this the only symptom is a preview that
# reports "no Supabase project configured" when you tap sign in, and
# nothing anywhere explains that the reason is an unset repository
# variable rather than a broken build - which is exactly the wrong
# way round for the one place this is meant to be testable.
if [ -z "$URL" ] || [ -z "$KEY" ]; then
echo "::warning::SUPABASE_URL and/or SUPABASE_ANON_KEY are not set as repository variables, so this preview cannot sign anyone in. See LAUNCH_CHECKLIST.md 4.3a."
else
echo "Supabase project: $URL"
fi
{
echo "url=$URL"
echo "key=$KEY"
} >> "$GITHUB_OUTPUT"
- name: Build the app
if: steps.can.outputs.deploy == 'true' && github.event.action != 'closed'
working-directory: client
env:
# The root, because a Cloudflare preview gets a hostname to itself
# rather than a subdirectory of the production site. That also means
# the installed PWA's scope is the whole origin here, which is closer
# to how the real app is served than the old /OurHike/pr-preview/...
# path ever was - see the note on VITE_BASE_PATH in pages.yml.
VITE_BASE_PATH: /
VITE_DATA_BASE_URL: ${{ steps.data.outputs.url }}
VITE_SUPABASE_URL: ${{ steps.supabase.outputs.url }}
VITE_SUPABASE_ANON_KEY: ${{ steps.supabase.outputs.key }}
VITE_AUTH_PROVIDERS: ${{ vars.AUTH_PROVIDERS }}
# VITE_API_BASE_URL is deliberately NOT set here, unlike in pages.yml.
# A preview is for looking at a change, and the only backend variable
# that exists names the real database - so a preview that could send
# would file test reports into the moderation queue a club works from.
# Reports still queue in the outbox exactly as they do offline, which
# is the behaviour worth previewing anyway.
run: npm run build
- name: Publish the preview
id: deploy
if: steps.can.outputs.deploy == 'true' && github.event.action != 'closed'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# `--branch` is what Cloudflare turns into the preview's stable
# hostname. It is not a git branch as far as this upload is
# concerned, and deliberately not named after one: a git branch name
# can be long, mixed case, and full of slashes, none of which survive
# being a DNS label intact.
command: >-
pages deploy client/dist
--project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT }}
--branch=${{ steps.preview.outputs.alias }}
--commit-dirty=true
# Cloudflare mints the `pr-<n>` alias the first time a pull request
# deploys, and its edge answers 522 for a short while before that alias
# routes anywhere. Uploading and being reachable are not the same event,
# and the gap lands exactly where it does the most damage: the comment
# would arrive the moment the upload finished, so the first person to
# click the link is the most likely to get the error.
#
# Waiting here is what makes the link worth trusting - if the comment
# exists, the URL in it answered. The bound matters as much as the wait:
# a preview that never comes up must still be reported, with the
# immutable deployment URL alongside it, rather than silently costing a
# reviewer their link because a step sat there until the job timed out.
- name: Wait for the preview to answer
id: wait
if: steps.can.outputs.deploy == 'true' && github.event.action != 'closed'
env:
URL: ${{ steps.deploy.outputs.pages-deployment-alias-url || steps.preview.outputs.url }}
IMMUTABLE: ${{ steps.deploy.outputs.deployment-url }}
run: |
attempts=30
note=""
for attempt in $(seq 1 "$attempts"); do
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$URL" || echo 000)"
if [ "$code" = "200" ]; then
echo "The preview answered 200 on attempt $attempt."
break
fi
if [ "$attempt" -eq "$attempts" ]; then
echo "::warning::The preview alias still answers $code after $attempts attempts. Posting the link anyway - it usually routes shortly - with the immutable deployment URL alongside it."
note="The alias above is still being routed and may return a Cloudflare 522 for a few more minutes. This exact build is already live at <$IMMUTABLE>."
break
fi
echo "Attempt $attempt: $code. Waiting for the alias to route."
sleep 5
done
{
echo "note<<PREVIEW_NOTE_EOF"
echo "$note"
echo "PREVIEW_NOTE_EOF"
} >> "$GITHUB_OUTPUT"
- name: Say where the preview is
if: steps.can.outputs.deploy == 'true' && github.event.action != 'closed'
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: pr-preview
message: |
### Preview
**[Open this change in the app](${{ steps.deploy.outputs.pages-deployment-alias-url || steps.preview.outputs.url }})**
Built from ${{ github.event.pull_request.head.sha }}. The link stays the
same as you push - it always serves the latest build of this pull request.
${{ steps.wait.outputs.note }}
Signing in works. Sending a report does not: a preview is built with no
backend on purpose, so reports queue in the outbox rather than landing in
the moderation queue a club works from.
# Cloudflare does not expire preview deployments, so without this a
# closed pull request's build stays reachable forever - built against the
# real Supabase project, and vouched for by nothing. See the action.
#
# continue-on-error so that the comment below still gets posted when this
# fails. A failed cleanup and a silent one are very different things: the
# first leaves previews up and says so, the second leaves them up while
# the pull request claims they are gone. The job is failed afterwards, on
# the step's own outputs, so neither the log nor the comment can be the
# only place it shows.
- name: Remove the previews
id: teardown
if: steps.can.outputs.deploy == 'true' && github.event.action == 'closed'
continue-on-error: true
uses: ./.github/actions/delete-pages-previews
with:
api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
project: ${{ vars.CLOUDFLARE_PAGES_PROJECT }}
alias: ${{ steps.preview.outputs.alias }}
- name: Say the pull request is closed
if: steps.can.outputs.deploy == 'true' && github.event.action == 'closed'
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: pr-preview
message: |
### Preview
This pull request is closed, and its preview deployments have been removed
(${{ steps.teardown.outputs.deleted || 0 }} of ${{ steps.teardown.outputs.found || 0 }}).
`${{ steps.preview.outputs.url }}` no longer serves anything.
${{ steps.teardown.outputs.failed != '0' && steps.teardown.outputs.failed != '' && '**Some could not be removed and are still reachable** - see the run log for which, and why.' || '' }}
# Last, so that the comment above is already posted when it runs. A red
# check on a closed pull request is not something anyone goes looking at,
# which is why the outcome is on the pull request too - but it still has
# to be red, or a cleanup that stopped working would look exactly like
# one that never had anything to do.
- name: Fail if any preview outlived its pull request
if: steps.can.outputs.deploy == 'true' && github.event.action == 'closed'
env:
FAILED: ${{ steps.teardown.outputs.failed }}
OUTCOME: ${{ steps.teardown.outcome }}
run: |
if [ "$OUTCOME" != "success" ] || { [ -n "$FAILED" ] && [ "$FAILED" != "0" ]; }; then
echo "::error::Removing this pull request's previews did not complete. They are still reachable."
exit 1
fi
echo "Every preview for this pull request has been removed."