Repository navigation
291 lines (270 loc) · 14.3 KB
/
Copy pathpreview.yml
File metadata and controls
291 lines (270 loc) · 14.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
name: Preview
# Per-PR preview deployments, on the Cloudflare account named by the CLOUDFLARE_ACCOUNT_ID
# repository variable.
#
# SECURITY — read this before changing the triggers.
#
# Deploying a preview needs a Cloudflare API token that can create Workers, KV namespaces and R2
# buckets on a Cloudflare-owned account. This repository is public, so anyone can open a pull
# request. The load-bearing control is the TRIGGER, not any `if:` below: on a public repository
# GitHub structurally withholds repository secrets from `pull_request` runs whose head is a fork,
# so `secrets.CLOUDFLARE_API_TOKEN` interpolates to the empty string there and no fork PR can
# deploy anything. That is the same guarantee workers-sdk's deploy-previews.yml relies on.
#
# Therefore: `pull_request` only. Never `pull_request_target`, never `workflow_run`, never
# `issue_comment` — each of those runs privileged with the secret available while the code, the
# PR number, or the artifact naming it comes from an untrusted contributor.
#
# The `if:` conditions and the in-job guard are defence in depth, each fail-closed, and exist so
# that a future edit which weakens the trigger still does not leak the token. They are not what
# makes this safe today.
#
# Deliberately absent from those conditions: `author_association`. GitHub computes that field for
# the payload with no viewer, so it cannot see private organization membership — a Cloudflare
# member whose membership is private, and who reaches this repository through a team rather than a
# direct collaborator grant, arrives as `CONTRIBUTOR` and had every preview silently skipped. Do
# not add it back. `head.repo.id == base.repo.id` is the stronger statement in any case, since
# pushing a branch into this repository already requires write access, and the first step of each
# job re-checks the author's permission against the API and fails closed.
#
# The setup-vp `cache: true` below is deliberate and is not a poisoning path: a fork PR's Actions
# cache is scoped to `refs/pull/<n>/merge` and cannot be read from another PR or from `main`.
on:
pull_request:
types: [opened, synchronize, reopened, closed]
schedule:
# Nightly, off the hour. GitHub has no equivalent of GitLab's `environment.auto_stop_in`, so
# this is what stops abandoned previews from leaking a KV pair and an R2 bucket each.
- cron: "37 4 * * *"
workflow_dispatch:
# Escalated per job. The preview jobs need no write scope at all beyond the sticky comment.
permissions: {}
env:
NODE_VERSION: "24.19.0"
jobs:
deploy:
name: Deploy preview
if: >-
github.event_name == 'pull_request' &&
github.event.action != 'closed' &&
github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id &&
github.event.pull_request.user.type != 'Bot' &&
github.head_ref != 'main' &&
github.repository_owner == 'cloudflare'
runs-on: ubuntu-latest
timeout-minutes: 45
concurrency:
# Never cancel: a half-applied preview is worse than a slow one, since the tiers are
# deployed in sequence and an interrupted run leaves the instance wired to stale previews.
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
steps:
# First, before any step can reference a secret. Catches a future edit that flips the
# trigger to `pull_request_target`, and a branch pushed by a since-demoted account or a bot.
- name: Verify the pull request is from a maintainer
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
permission=$(gh api "repos/$GH_REPO/collaborators/$PR_AUTHOR/permission" \
--jq '.permission')
if [[ ! "$permission" =~ ^(admin|maintain|write)$ ]]; then
echo "$PR_AUTHOR has '$permission' on $GH_REPO; previews require write access."
exit 1
fi
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Vite+ downloads its own pnpm for `vp install` and exposes no shim, so this is what puts
# `pnpm` on PATH — for the steps below, and for the bare `pnpm` preview.ts spawns.
- name: Enable Corepack
run: corepack enable
# Vite+ resolves its own version from the `vite-plus` catalog entry, and installs the pnpm
# named in `packageManager` (integrity hash checked as Corepack does), so setup-node is not
# needed.
- name: Set up Vite+, Node.js and dependencies
uses: voidzero-dev/setup-vp@cab1ab9eaff52d4857875f4f2c016e36af8f2c76 # v1.22.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: true
run-install: true
- name: Deploy preview
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }}
# Secrets, not vars: these three are uploaded to the backend's Preview base config with
# `wrangler preview base-config secret bulk`, and never written into a config file —
# Wrangler prints the values it finds in one, and this log is public.
PREVIEW_ADMINS: ${{ secrets.PREVIEW_ADMINS }}
CF_ACCESS_AUD: ${{ secrets.CF_ACCESS_AUD }}
CF_ACCESS_ISS: ${{ secrets.CF_ACCESS_ISS }}
# AI Gateway, so a preview's chats use server-managed keys rather than asking each user
# for their own. Optional as a group: with CF_AI_GATEWAY unset the preview is BYOK, but
# once it is set the account id and the token are required.
CF_AI_GATEWAY: ${{ secrets.CF_AI_GATEWAY }}
# CF_OS_-prefixed so it cannot be mistaken for the account the previews deploy to; it is
# uploaded as CF_AI_GATEWAY_ACCOUNT_ID.
CF_AI_GATEWAY_ACCOUNT_ID: ${{ secrets.CF_OS_AI_GATEWAY_ACCOUNT_ID }}
CF_AI_GATEWAY_PROVIDERS: ${{ secrets.CF_AI_GATEWAY_PROVIDERS }}
# Optional: the Models tab's admin settings each preview starts with, a JSON object
# uploaded as the backend's ADMIN_CONFIG_SEED (see resolveAdminConfigSeed).
PREVIEW_ADMIN_CONFIG_SEED: ${{ secrets.PREVIEW_ADMIN_CONFIG_SEED }}
# The gateway above lives in a different account from the one these previews deploy to,
# and the backend's WORKERS_AI binding only reaches gateways in its own account. So
# inference and cost-log reads go over HTTPS with this AI Gateway Run + Read token,
# scoped to the gateway's account. WORKERS_AI stays bound for webFetch's Markdown
# conversion.
CF_AI_GATEWAY_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_API_TOKEN }}
CF_AI_GATEWAY_USE_BINDING: "false"
# The GitHub gatekeeper's OAuth app, uploaded to *that worker's* Preview base config the
# same way, and for the same reason, as the backend's three above. Optional: with both
# unset the preview still deploys and only the GitHub connector is dead in it.
#
# `PREVIEW_`-prefixed because GitHub refuses to store a repository secret whose name
# begins with `GITHUB_`. The app behind these must be one registered for previews alone,
# with the callback URL `https://<PREVIEW_WORKERS_DEV_HOST>/gatekeeper/github/oauth` and
# wildcard matching left on — every preview is a different subdomain of that host, so
# nothing narrower validates, and anything on that subdomain can receive its codes.
PREVIEW_GITHUB_CLIENT_ID: ${{ secrets.PREVIEW_GITHUB_CLIENT_ID }}
PREVIEW_GITHUB_CLIENT_SECRET: ${{ secrets.PREVIEW_GITHUB_CLIENT_SECRET }}
# Together these are the preview name, which is the first label of its hostname: a preview
# reads as `pr123-my-branch-router.<subdomain>.workers.dev`. The number is what keeps two
# branches that slugify alike from sharing one instance, and is how the nightly sweep
# matches a live preview back to its pull request. The cleanup job below passes both too.
PREVIEW_NAME: ${{ github.head_ref }}
PREVIEW_PR_NUMBER: ${{ github.event.pull_request.number }}
run: node scripts/preview/preview.ts deploy
# In this same job, deliberately: handing the comment to a privileged second workflow is
# the pattern workers-sdk deleted, because the privileged half read the PR number out of an
# artifact the unprivileged half had named.
- name: Comment the preview URL
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
MARKER: "<!-- preview-deployment -->"
run: |
{
printf '%s\n\n' "$MARKER"
cat output/preview-comment.md
} > output/preview-comment-body.md
jq -n --rawfile body output/preview-comment-body.md '{body: $body}' \
> output/preview-comment.json
# No `| head -1`: the shell runs with `pipefail`, and gh would take a SIGPIPE.
matches=$(gh api "repos/$GH_REPO/issues/$PR_NUMBER/comments" --paginate --jq '
first(.[]
| select(.user.login == "github-actions[bot]")
| select(.body | startswith(env.MARKER))
| .id)')
id=${matches%%$'\n'*}
if [[ -n "$id" ]]; then
gh api --silent --method PATCH "repos/$GH_REPO/issues/comments/$id" \
--input output/preview-comment.json
else
gh api --silent --method POST "repos/$GH_REPO/issues/$PR_NUMBER/comments" \
--input output/preview-comment.json
fi
cleanup:
name: Delete preview
if: >-
github.event_name == 'pull_request' &&
github.event.action == 'closed' &&
github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id &&
github.event.pull_request.user.type != 'Bot' &&
github.head_ref != 'main' &&
github.repository_owner == 'cloudflare'
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
contents: read
steps:
- name: Verify the pull request is from a maintainer
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
permission=$(gh api "repos/$GH_REPO/collaborators/$PR_AUTHOR/permission" \
--jq '.permission')
if [[ ! "$permission" =~ ^(admin|maintain|write)$ ]]; then
echo "$PR_AUTHOR has '$permission' on $GH_REPO; previews require write access."
exit 1
fi
# The PR's own head commit, not the default branch: the teardown has to read the package list
# and wrangler configs that *created* these previews
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
persist-credentials: false
# Vite+ downloads its own pnpm for `vp install` and exposes no shim, so this is what puts
# `pnpm` on PATH — for the steps below, and for the bare `pnpm` preview.ts spawns.
- name: Enable Corepack
run: corepack enable
# Vite+ resolves its own version from the `vite-plus` catalog entry, and installs the pnpm
# named in `packageManager` (integrity hash checked as Corepack does), so setup-node is not
# needed.
- name: Set up Vite+, Node.js and dependencies
uses: voidzero-dev/setup-vp@cab1ab9eaff52d4857875f4f2c016e36af8f2c76 # v1.22.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: true
run-install: true
- name: Delete preview
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }}
# The same pair the deploy job passed, which is what names the preview. GitHub sets both on
# the closed event too. No admin or Access secret is needed to tear one down.
PREVIEW_NAME: ${{ github.head_ref }}
PREVIEW_PR_NUMBER: ${{ github.event.pull_request.number }}
run: node scripts/preview/preview.ts delete
sweep:
name: Sweep abandoned previews
if: >-
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') &&
github.repository_owner == 'cloudflare'
runs-on: ubuntu-latest
timeout-minutes: 60
concurrency:
group: preview-sweep
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Vite+ downloads its own pnpm for `vp install` and exposes no shim, so this is what puts
# `pnpm` on PATH — for the steps below, and for the bare `pnpm` preview.ts spawns.
- name: Enable Corepack
run: corepack enable
# Vite+ resolves its own version from the `vite-plus` catalog entry, and installs the pnpm
# named in `packageManager` (integrity hash checked as Corepack does), so setup-node is not
# needed.
- name: Set up Vite+, Node.js and dependencies
uses: voidzero-dev/setup-vp@cab1ab9eaff52d4857875f4f2c016e36af8f2c76 # v1.22.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: true
run-install: true
- name: Sweep previews whose PR is closed, or older than 7 days
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }}
# Reads one pull request per number parsed out of a live preview's name.
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/preview/preview.ts sweep