Skip to content

Commit 4e7add7

Browse files
committed
Add per-PR Worker Preview deploys
Every PR gets an isolated deployment of all 18 workers as Worker Previews, reachable at the router preview's workers.dev URL. `scripts/preview/` generates a `wrangler.staging.jsonc` per package (gitignored build output), deploys the three tiers in dependency order, patches each service binding at the sibling preview, and comments the URL on the PR; the close event deletes the preview and a nightly sweep collects strays.
1 parent 046bcd7 commit 4e7add7

13 files changed

Lines changed: 2237 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ env:
1313
NODE_VERSION: "24.19.0"
1414

1515
jobs:
16-
# Parallel to `test` so lint and test failures surface on the same run; type checking lives in Build.
16+
# Parallel to `test` so lint and test failures surface on the same run. Package type checking
17+
# lives in Build; `scripts/` is checked here instead, because it is deliberately kept off
18+
# `pnpm build`'s hot path (see AGENTS.md) and this is the cheap job.
1719
lint:
1820
name: Lint
1921
runs-on: ubuntu-latest
@@ -39,6 +41,9 @@ jobs:
3941
- name: Lint
4042
run: pnpm lint:check
4143

44+
- name: Type-check scripts/
45+
run: pnpm types:scripts
46+
4247
test:
4348
name: Build and test
4449
runs-on: ubuntu-latest

.github/workflows/preview.yml

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
name: Preview
2+
3+
# Per-PR preview deployments, on the Cloudflare account named by the CLOUDFLARE_ACCOUNT_ID
4+
# repository variable.
5+
#
6+
# SECURITY — read this before changing the triggers.
7+
#
8+
# Deploying a preview needs a Cloudflare API token that can create Workers, KV namespaces and R2
9+
# buckets on a Cloudflare-owned account. This repository is public, so anyone can open a pull
10+
# request. The load-bearing control is the TRIGGER, not any `if:` below: on a public repository
11+
# GitHub structurally withholds repository secrets from `pull_request` runs whose head is a fork,
12+
# so `secrets.CLOUDFLARE_API_TOKEN` interpolates to the empty string there and no fork PR can
13+
# deploy anything. That is the same guarantee workers-sdk's deploy-previews.yml relies on.
14+
#
15+
# Therefore: `pull_request` only. Never `pull_request_target`, never `workflow_run`, never
16+
# `issue_comment` — each of those runs privileged with the secret available while the code, the
17+
# PR number, or the artifact naming it comes from an untrusted contributor.
18+
#
19+
# The `if:` conditions and the in-job guard are defence in depth, each fail-closed, and exist so
20+
# that a future edit which weakens the trigger still does not leak the token. They are not what
21+
# makes this safe today.
22+
#
23+
# The `cache: pnpm` below is deliberate and is not a poisoning path: a fork PR's Actions cache is
24+
# scoped to `refs/pull/<n>/merge` and cannot be read from another PR or from `main`.
25+
26+
on:
27+
pull_request:
28+
types: [opened, synchronize, reopened, closed]
29+
schedule:
30+
# Nightly, off the hour. GitHub has no equivalent of GitLab's `environment.auto_stop_in`, so
31+
# this is what stops abandoned previews from leaking a KV pair and an R2 bucket each.
32+
- cron: "37 4 * * *"
33+
workflow_dispatch:
34+
35+
# Escalated per job. The preview jobs need no write scope at all beyond the sticky comment.
36+
permissions: {}
37+
38+
env:
39+
NODE_VERSION: "24.19.0"
40+
41+
jobs:
42+
deploy:
43+
name: Deploy preview
44+
if: >-
45+
github.event_name == 'pull_request' &&
46+
github.event.action != 'closed' &&
47+
github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id &&
48+
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) &&
49+
github.event.pull_request.user.type != 'Bot' &&
50+
github.head_ref != 'main' &&
51+
github.repository_owner == 'cloudflare'
52+
runs-on: ubuntu-latest
53+
timeout-minutes: 45
54+
concurrency:
55+
# Never cancel: a half-applied preview is worse than a slow one, since the tiers are
56+
# deployed in sequence and an interrupted run leaves the instance wired to stale previews.
57+
group: preview-${{ github.event.pull_request.number }}
58+
cancel-in-progress: false
59+
permissions:
60+
contents: read
61+
pull-requests: write
62+
steps:
63+
# First, before any step can reference a secret. Catches a future edit that flips the
64+
# trigger to `pull_request_target`, and a branch pushed by a since-demoted account or a bot.
65+
- name: Verify the pull request is from a maintainer
66+
env:
67+
GH_TOKEN: ${{ github.token }}
68+
GH_REPO: ${{ github.repository }}
69+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
70+
run: |
71+
permission=$(gh api "repos/$GH_REPO/collaborators/$PR_AUTHOR/permission" \
72+
--jq '.permission')
73+
if [[ ! "$permission" =~ ^(admin|maintain|write)$ ]]; then
74+
echo "$PR_AUTHOR has '$permission' on $GH_REPO; previews require write access."
75+
exit 1
76+
fi
77+
78+
- name: Check out repository
79+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
80+
with:
81+
persist-credentials: false
82+
83+
# Ahead of setup-node, which shells out to `pnpm store path` to find the directory it caches.
84+
- name: Enable Corepack
85+
run: corepack enable
86+
87+
- name: Set up Node.js
88+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
89+
with:
90+
node-version: ${{ env.NODE_VERSION }}
91+
cache: pnpm
92+
93+
- name: Install dependencies
94+
run: pnpm install --frozen-lockfile
95+
96+
- name: Deploy preview
97+
env:
98+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
99+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
100+
PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }}
101+
# Secrets, not vars: these three are uploaded to the backend's Previews settings with
102+
# `wrangler preview secret bulk`, and never written into a config file — Wrangler prints
103+
# the values it finds in one, and this log is public.
104+
PREVIEW_ADMINS: ${{ secrets.PREVIEW_ADMINS }}
105+
CF_ACCESS_AUD: ${{ secrets.CF_ACCESS_AUD }}
106+
CF_ACCESS_ISS: ${{ secrets.CF_ACCESS_ISS }}
107+
# AI Gateway, so a preview's chats use server-managed keys rather than asking each user
108+
# for their own. Optional as a group: with CF_AI_GATEWAY unset the preview is BYOK, but
109+
# once it is set the account id and the Run + Read token are required.
110+
CF_AI_GATEWAY: ${{ secrets.CF_AI_GATEWAY }}
111+
# This is delibaretely set to CF_OS_AI_GATEWAY_ACCOUNT_ID (gets uploaded as CF_AI_GATEWAY_ACCOUNT_ID)
112+
CF_AI_GATEWAY_ACCOUNT_ID: ${{ secrets.CF_OS_AI_GATEWAY_ACCOUNT_ID }}
113+
CF_AI_GATEWAY_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_API_TOKEN }}
114+
CF_AI_GATEWAY_PROVIDERS: ${{ secrets.CF_AI_GATEWAY_PROVIDERS }}
115+
CF_AI_GATEWAY_WAI_DIRECT: ${{ secrets.CF_AI_GATEWAY_WAI_DIRECT }}
116+
# The branch, because the preview name is the first label of its hostname: a preview reads
117+
# as `my-branch-router.<subdomain>.workers.dev`. The cleanup job below passes the
118+
# same ref; the nightly sweep matches previews back to pull requests by slugifying every
119+
# recent head ref the same way.
120+
PREVIEW_NAME: ${{ github.head_ref }}
121+
run: node scripts/preview/preview.ts deploy
122+
123+
# In this same job, deliberately: handing the comment to a privileged second workflow is
124+
# the pattern workers-sdk deleted, because the privileged half read the PR number out of an
125+
# artifact the unprivileged half had named.
126+
- name: Comment the preview URL
127+
env:
128+
GH_TOKEN: ${{ github.token }}
129+
GH_REPO: ${{ github.repository }}
130+
PR_NUMBER: ${{ github.event.pull_request.number }}
131+
MARKER: "<!-- preview-deployment -->"
132+
run: |
133+
{
134+
printf '%s\n\n' "$MARKER"
135+
cat output/preview-comment.md
136+
} > output/preview-comment-body.md
137+
jq -n --rawfile body output/preview-comment-body.md '{body: $body}' \
138+
> output/preview-comment.json
139+
140+
# No `| head -1`: the shell runs with `pipefail`, and gh would take a SIGPIPE.
141+
matches=$(gh api "repos/$GH_REPO/issues/$PR_NUMBER/comments" --paginate --jq '
142+
first(.[]
143+
| select(.user.login == "github-actions[bot]")
144+
| select(.body | startswith(env.MARKER))
145+
| .id)')
146+
id=${matches%%$'\n'*}
147+
148+
if [[ -n "$id" ]]; then
149+
gh api --silent --method PATCH "repos/$GH_REPO/issues/comments/$id" \
150+
--input output/preview-comment.json
151+
else
152+
gh api --silent --method POST "repos/$GH_REPO/issues/$PR_NUMBER/comments" \
153+
--input output/preview-comment.json
154+
fi
155+
156+
cleanup:
157+
name: Delete preview
158+
if: >-
159+
github.event_name == 'pull_request' &&
160+
github.event.action == 'closed' &&
161+
github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id &&
162+
github.event.pull_request.user.type != 'Bot' &&
163+
github.head_ref != 'main' &&
164+
github.repository_owner == 'cloudflare'
165+
runs-on: ubuntu-latest
166+
timeout-minutes: 30
167+
concurrency:
168+
group: preview-${{ github.event.pull_request.number }}
169+
cancel-in-progress: false
170+
permissions:
171+
contents: read
172+
steps:
173+
# The default branch, not the PR's merge ref: a teardown needs the branch's name and nothing
174+
# else from it.
175+
- name: Check out repository
176+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
177+
with:
178+
ref: ${{ github.event.repository.default_branch }}
179+
persist-credentials: false
180+
181+
- name: Enable Corepack
182+
run: corepack enable
183+
184+
- name: Set up Node.js
185+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
186+
with:
187+
node-version: ${{ env.NODE_VERSION }}
188+
cache: pnpm
189+
190+
- name: Install dependencies
191+
run: pnpm install --frozen-lockfile
192+
193+
- name: Delete preview
194+
env:
195+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
196+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
197+
PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }}
198+
# The same ref the deploy job used, which is what names the preview. GitHub sets it on the
199+
# closed event too. No admin or Access secret is needed to tear one down.
200+
PREVIEW_NAME: ${{ github.head_ref }}
201+
run: node scripts/preview/preview.ts delete
202+
203+
sweep:
204+
name: Sweep abandoned previews
205+
if: >-
206+
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') &&
207+
github.repository_owner == 'cloudflare'
208+
runs-on: ubuntu-latest
209+
timeout-minutes: 60
210+
concurrency:
211+
group: preview-sweep
212+
cancel-in-progress: false
213+
permissions:
214+
contents: read
215+
pull-requests: read
216+
steps:
217+
- name: Check out repository
218+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
219+
with:
220+
persist-credentials: false
221+
222+
- name: Enable Corepack
223+
run: corepack enable
224+
225+
- name: Set up Node.js
226+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
227+
with:
228+
node-version: ${{ env.NODE_VERSION }}
229+
cache: pnpm
230+
231+
- name: Install dependencies
232+
run: pnpm install --frozen-lockfile
233+
234+
- name: Sweep previews whose PR is closed, or older than 7 days
235+
env:
236+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
237+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
238+
PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }}
239+
# Reads every recent pull request's head branch, to match live previews back to them.
240+
GITHUB_TOKEN: ${{ github.token }}
241+
run: node scripts/preview/preview.ts sweep

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ wrangler.staging.jsonc
3131
# Site-specific dev server configs.
3232
wrangler.dev.jsonc
3333

34+
# Scratch output from scripts/preview (the PR-preview comment body).
35+
/output/
36+
3437
# macOS
3538
.DS_Store
3639

CONTRIBUTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,15 @@ With that said, we are happy to accept small, trivially-verified PRs that fix a
99
If you have a big idea you'd like us to consider, feel free to [open a discussion](https://github.com/cloudflare/cloudflare-os/discussions) about it.
1010

1111
This policy may change in the future as the project matures. Until then, thank you for your understanding.
12+
13+
## What CI runs on your pull request
14+
15+
Lint, build and tests ([`ci.yml`](.github/workflows/ci.yml)) run on every pull request, including
16+
those from forks.
17+
18+
Preview deployments ([`preview.yml`](.github/workflows/preview.yml)) do **not**, this is
19+
deliberate. Deploying a preview requires a Cloudflare API token that can create Workers
20+
and storage on a Cloudflare-owned account, and GitHub structurally withholds repository secrets
21+
from `pull_request` runs whose head is a fork. If you see the preview job skipped on your PR,
22+
that is working as intended — a maintainer will deploy one if the change needs manual review.
23+
See [`.github/workflows/README.md`](.github/workflows/README.md).

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@
77
"scripts": {
88
"build": "vp run -r --cache build",
99
"run-local": "node scripts/run-local.mjs",
10-
"test": "node --test scripts/*.test.js && vp run --filter '!cloudflare-os' --cache test",
10+
"test": "node --test 'scripts/**/*.test.js' 'scripts/**/*.test.ts' && vp run --filter '!cloudflare-os' --cache test",
11+
"preview:config": "node scripts/preview/preview.ts config",
12+
"preview:deploy": "node scripts/preview/preview.ts deploy",
13+
"preview:delete": "node scripts/preview/preview.ts delete",
14+
"preview:sweep": "node scripts/preview/preview.ts sweep",
1115
"dev-client": "cd packages/workshop-frontend && pnpm run dev",
1216
"dev-server": "node run-dev-server.js",
1317
"clean": "vp run -r clean",
1418
"lint:check": "vp lint",
1519
"lint:fix": "vp lint --fix",
1620
"types:check": "pnpm run build",
17-
"lint": "pnpm run lint:check && pnpm run types:check",
21+
"types:scripts": "tsc -p scripts/tsconfig.json",
22+
"lint": "pnpm run lint:check && pnpm run types:scripts && pnpm run types:check",
1823
"types:generate": "node scripts/generate-worker-types.mjs"
1924
},
2025
"devDependencies": {
26+
"@types/node": "26.1.0",
2127
"aws4fetch": "^1.0.20",
2228
"jsonc-parser": "^3.3.1",
2329
"typescript": "catalog:",

packages/workshop-backend/src/env.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import type { ProductAnalyticsRecord } from "./analytics";
66
declare global {
77
namespace Cloudflare {
88
interface Env {
9-
// Deployment-wide admin usernames.
10-
ADMINS?: string[];
9+
// Deployment-wide admin usernames: a JSON binding, or the same array as a JSON string
10+
// (which is what a secret binding, can carry).
11+
ADMINS?: string[] | string;
1112

1213
// Workers AI binding (injected by generate-wrangler-prod / run-dev-server; not in base wrangler.jsonc).
1314
WORKERS_AI: Ai;

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/env-passthrough.test.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,16 @@ const EXPECTED = {
5555
},
5656
// `build-gatekeeper-configurator.mjs` is covered in detail by
5757
// build-gatekeeper-configurator.test.js, which pins its reads against the shared task's `env`.
58-
// `build-release.mjs` and `run-local.mjs` are invoked directly, never as vp tasks.
58+
// `build-release.mjs`, `run-local.mjs` and `preview/` are invoked directly, never as vp tasks.
5959
scripts: {
6060
forwarded: ["VITE_FRONTEND_ERROR_REPORTING"],
61-
external: ["CI_COMMIT_SHA", "CI_PIPELINE_IID", "VITE_BACKEND_HOST"],
61+
external: [
62+
"CF_ACCESS_AUD", "CF_ACCESS_ISS", "CF_AI_GATEWAY", "CF_AI_GATEWAY_ACCOUNT_ID",
63+
"CF_AI_GATEWAY_API_TOKEN", "CF_AI_GATEWAY_PROVIDERS", "CF_AI_GATEWAY_WAI_DIRECT",
64+
"CI_COMMIT_SHA", "CI_PIPELINE_IID", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN",
65+
"GITHUB_REPOSITORY", "GITHUB_TOKEN", "PREVIEW_ADMINS", "PREVIEW_NAME",
66+
"PREVIEW_WORKERS_DEV_HOST", "PREVIEW_WRANGLER", "VITE_BACKEND_HOST",
67+
],
6268
},
6369
};
6470

0 commit comments

Comments
 (0)