History

Add per-PR Cloudflare preview deploys (#953)

Every same-repo pull request gets an isolated preview of the Cloudflare host on a dedicated preview account: its own Worker, D1 database, encryption key, and Cloudflare Access application (MCP-OAuth-aware, edge-enforced on the workers.dev hostname), deployed on PR open/sync and destroyed on close. A nightly sweeper removes previews whose PR is no longer open. Teardown is stateless: every resource a preview owns is derived from its PR number.

Rhys Sullivan committed 5 months ago

Commit 1dcebe61 parent 23b97ef5 files · +487 −3Browse files

Unified · Split

5 files
  1. .github/workflows/preview-sweep.yml +43 −0
  2. .github/workflows/preview.yml +117 −0
  3. .oxlintrc.jsonc +13 −3
  4. apps/host-cloudflare/.gitignore +1 −0
  5. apps/host-cloudflare/scripts/preview.ts +313 −0
.github/workflows/preview-sweep.yml+43−0
@@ -0,0 +1,43 @@
1name: Preview sweep
2
3# Safety net for the per-PR previews (.github/workflows/preview.yml): force
4# pushes, cancelled runs, or teardown failures can orphan a preview stack.
5# Nightly, list every deployed preview and destroy any whose PR is no longer
6# open.
7
8on:
9 schedule:
10 - cron: "23 5 * * *"
11 workflow_dispatch:
12
13permissions:
14 contents: read
15 pull-requests: read
16
17jobs:
18 sweep:
19 name: Destroy previews for closed PRs
20 runs-on: ubuntu-latest
21 steps:
22 - uses: actions/checkout@v4
23
24 - uses: oven-sh/setup-bun@v2
25 with:
26 bun-version: 1.3.11
27
28 - name: Sweep
29 env:
30 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }}
31 CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_PREVIEW_ACCOUNT_ID }}
32 GH_TOKEN: ${{ github.token }}
33 run: |
34 set -euo pipefail
35 for pr in $(bun apps/host-cloudflare/scripts/preview.ts list | jq -r '.[].pr'); do
36 state="$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json state -q .state 2>/dev/null || echo UNKNOWN)"
37 if [ "$state" != "OPEN" ]; then
38 echo "PR #$pr is $state — destroying its preview"
39 bun apps/host-cloudflare/scripts/preview.ts destroy --pr "$pr"
40 else
41 echo "PR #$pr is open — keeping its preview"
42 fi
43 done
.github/workflows/preview.yml+117−0
@@ -0,0 +1,117 @@
1name: Preview
2
3# Per-PR preview deploys of the Cloudflare host to a dedicated preview account.
4# Every same-repo PR gets its own isolated stack (Worker + D1 + Access app)
5# behind Cloudflare Access; it is torn down when the PR closes. Fork PRs are
6# skipped — they must not run with the deploy token.
7#
8# Required repo configuration:
9# secret CLOUDFLARE_PREVIEW_API_TOKEN — scoped token (Workers/D1/R2/Access edit)
10# var CLOUDFLARE_PREVIEW_ACCOUNT_ID — the preview Cloudflare account
11# var PREVIEW_ACCESS_TEAM_DOMAIN — Zero Trust team domain
12# var PREVIEW_ACCESS_EMAILS — comma-separated emails allowed through Access
13
14on:
15 pull_request:
16 types: [opened, reopened, synchronize, closed]
17
18permissions:
19 contents: read
20 pull-requests: write
21
22concurrency:
23 group: preview-${{ github.event.pull_request.number }}
24 cancel-in-progress: ${{ github.event.action != 'closed' }}
25
26jobs:
27 deploy:
28 name: Deploy preview
29 if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
30 runs-on: ubuntu-latest
31 steps:
32 - uses: actions/checkout@v4
33
34 - uses: oven-sh/setup-bun@v2
35 with:
36 bun-version: 1.3.11
37
38 - run: bun install --frozen-lockfile
39
40 - name: Deploy
41 id: deploy
42 working-directory: apps/host-cloudflare
43 env:
44 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }}
45 CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_PREVIEW_ACCOUNT_ID }}
46 PREVIEW_ACCESS_TEAM_DOMAIN: ${{ vars.PREVIEW_ACCESS_TEAM_DOMAIN }}
47 PREVIEW_ACCESS_EMAILS: ${{ vars.PREVIEW_ACCESS_EMAILS }}
48 run: bun scripts/preview.ts deploy --pr ${{ github.event.pull_request.number }}
49
50 - name: Comment preview URL
51 uses: actions/github-script@v7
52 env:
53 PREVIEW_URL: ${{ steps.deploy.outputs.url }}
54 with:
55 script: |
56 const marker = "<!-- executor-preview -->";
57 const body = [
58 marker,
59 "### Cloudflare preview",
60 "",
61 `| | |`,
62 `|---|---|`,
63 `| Console | ${process.env.PREVIEW_URL} |`,
64 `| MCP | \`${process.env.PREVIEW_URL}/mcp\` |`,
65 `| Deployed commit | ${context.payload.pull_request.head.sha} |`,
66 "",
67 "Sign-in is Cloudflare Access (one-time PIN to an allowed email). " +
68 "The preview has its own database and encryption key; it is destroyed when this PR closes.",
69 ].join("\n");
70 const { data: comments } = await github.rest.issues.listComments({
71 ...context.repo,
72 issue_number: context.issue.number,
73 per_page: 100,
74 });
75 const existing = comments.find((c) => c.body && c.body.startsWith(marker));
76 if (existing) {
77 await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
78 } else {
79 await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body });
80 }
81
82 teardown:
83 name: Tear down preview
84 if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
85 runs-on: ubuntu-latest
86 steps:
87 - uses: actions/checkout@v4
88
89 - uses: oven-sh/setup-bun@v2
90 with:
91 bun-version: 1.3.11
92
93 # destroy talks straight to the Cloudflare API — no install needed.
94 - name: Destroy
95 env:
96 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }}
97 CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_PREVIEW_ACCOUNT_ID }}
98 run: bun apps/host-cloudflare/scripts/preview.ts destroy --pr ${{ github.event.pull_request.number }}
99
100 - name: Mark comment as torn down
101 uses: actions/github-script@v7
102 with:
103 script: |
104 const marker = "<!-- executor-preview -->";
105 const { data: comments } = await github.rest.issues.listComments({
106 ...context.repo,
107 issue_number: context.issue.number,
108 per_page: 100,
109 });
110 const existing = comments.find((c) => c.body && c.body.startsWith(marker));
111 if (existing) {
112 await github.rest.issues.updateComment({
113 ...context.repo,
114 comment_id: existing.id,
115 body: `${marker}\n### Cloudflare preview\n\nTorn down — the PR is closed.`,
116 });
117 }
.oxlintrc.jsonc+13−3
@@ -37,10 +37,19 @@ · 36 unmodified lines
37 "error",37 "error",
38 {38 {
39 "forbid": [39 "forbid": [
40 { "element": "button", "message": "Use <Button> from @executor/react" },40 {
41 "element": "button",
42 "message": "Use <Button> from @executor/react",
43 },
41 { "element": "input", "message": "Use <Input> from @executor/react" },44 { "element": "input", "message": "Use <Input> from @executor/react" },
42 { "element": "textarea", "message": "Use <Textarea> from @executor/react" },45 {
43 { "element": "select", "message": "Use <Select> or <NativeSelect> from @executor/react" },46 "element": "textarea",
47 "message": "Use <Textarea> from @executor/react",
48 },
49 {
50 "element": "select",
51 "message": "Use <Select> or <NativeSelect> from @executor/react",
52 },
44 { "element": "table", "message": "Use <Table> from @executor/react" },53 { "element": "table", "message": "Use <Table> from @executor/react" },
45 { "element": "label", "message": "Use <Label> from @executor/react" },54 { "element": "label", "message": "Use <Label> from @executor/react" },
46 ],55 ],
@@ -53,6 +62,7 @@ · 6 unmodified lines
53 "apps/cli/src/**/*.{ts,tsx}",62 "apps/cli/src/**/*.{ts,tsx}",
54 "apps/desktop/src/main.ts",63 "apps/desktop/src/main.ts",
55 "scripts/**/*.{ts,js}",64 "scripts/**/*.{ts,js}",
65 "apps/*/scripts/**/*.{ts,js}",
56 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",66 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",
57 ],67 ],
58 "rules": {68 "rules": {
apps/host-cloudflare/.gitignore+1−0
@@ -1,3 +1,4 @@
1dist/1dist/
2.dev.vars2.dev.vars
3.wrangler/3.wrangler/
4wrangler.preview.jsonc
apps/host-cloudflare/scripts/preview.ts+313−0
@@ -0,0 +1,313 @@
1// Per-PR preview deploys of the Cloudflare host.
2//
3// Each pull request gets its own fully isolated stack on a dedicated
4// Cloudflare account: a Worker (`executor-preview-pr-<n>`), its own D1
5// database, a fresh EXECUTOR_SECRET_KEY, and its own Cloudflare Access
6// application (MCP-OAuth-aware, gating the workers.dev hostname at the edge)
7// so previews are never publicly open. The R2 blob bucket is shared across
8// previews (`executor-preview-blobs`) — blobs are content-addressed overflow
9// values reached only via pointers in each preview's private D1, and leaving
10// the bucket in place keeps teardown trivial.
11//
12// Teardown is stateless by naming convention: everything a preview owns is
13// derived from its PR number, so `destroy` (and the scheduled sweeper) can
14// clean up without any deploy-time state.
15//
16// bun scripts/preview.ts deploy --pr 123 [--skip-build]
17// bun scripts/preview.ts destroy --pr 123
18// bun scripts/preview.ts list # JSON [{pr, name}] of live previews
19//
20// Env: CLOUDFLARE_API_TOKEN (Workers/D1/R2/Access edit) + CLOUDFLARE_ACCOUNT_ID
21// always; deploy additionally needs PREVIEW_ACCESS_TEAM_DOMAIN (Zero Trust team)
22// and PREVIEW_ACCESS_EMAILS (comma-separated emails allowed through Access; the
23// first one is also the admin unless PREVIEW_ADMIN_EMAILS overrides it).
24
25import { spawnSync } from "node:child_process";
26import { randomBytes } from "node:crypto";
27import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
28import { resolve } from "node:path";
29
30const CF_API = "https://api.cloudflare.com/client/v4";
31const APP_DIR = resolve(import.meta.dirname, "..");
32const WORKER_PREFIX = "executor-preview-pr-";
33const SHARED_BLOBS_BUCKET = "executor-preview-blobs";
34
35const TOKEN = process.env.CLOUDFLARE_API_TOKEN ?? "";
36const ACCOUNT = process.env.CLOUDFLARE_ACCOUNT_ID ?? "";
37
38const fail = (message: string): never => {
39 process.stderr.write(`${message}\n`);
40 process.exit(1);
41};
42
43interface CfFetchResult {
44 readonly ok: boolean;
45 readonly errors: string;
46 readonly result: any;
47}
48
49const cf = async (method: string, path: string, body?: unknown): Promise<CfFetchResult> => {
50 const response = await fetch(`${CF_API}${path}`, {
51 method,
52 headers: {
53 authorization: `Bearer ${TOKEN}`,
54 "content-type": "application/json",
55 },
56 body: body === undefined ? undefined : JSON.stringify(body),
57 });
58 const json: any = await response.json().catch(() => ({ success: false, errors: [] }));
59 return {
60 ok: json.success === true,
61 errors: JSON.stringify(json.errors ?? []),
62 result: json.result,
63 };
64};
65
66const cfOk = async (method: string, path: string, body?: unknown): Promise<any> => {
67 const response = await cf(method, path, body);
68 if (!response.ok) fail(`${method} ${path} failed: ${response.errors}`);
69 return response.result;
70};
71
72/** Paginated GET that concatenates every page of `result`. */
73const cfList = async (path: string): Promise<any[]> => {
74 const all: any[] = [];
75 const separator = path.includes("?") ? "&" : "?";
76 for (let page = 1; ; page++) {
77 const items = await cfOk("GET", `${path}${separator}page=${page}&per_page=100`);
78 all.push(...items);
79 if (items.length < 100) return all;
80 }
81};
82
83const run = (command: string, args: string[], options?: { input?: string; cwd?: string }): void => {
84 const result = spawnSync(command, args, {
85 cwd: options?.cwd ?? APP_DIR,
86 stdio: [options?.input === undefined ? "ignore" : "pipe", "inherit", "inherit"],
87 input: options?.input,
88 env: process.env,
89 });
90 if (result.status !== 0) fail(`${command} ${args.join(" ")} exited with ${result.status}`);
91};
92
93const prNumber = (): number => {
94 const index = process.argv.indexOf("--pr");
95 const value = index === -1 ? Number.NaN : Number(process.argv[index + 1]);
96 if (!Number.isInteger(value) || value < 0) fail("--pr <number> is required");
97 return value;
98};
99
100const workerName = (pr: number): string => `${WORKER_PREFIX}${pr}`;
101
102// --- resources ---------------------------------------------------------------
103
104const findD1 = async (name: string): Promise<string | null> => {
105 const databases = await cfList(`/accounts/${ACCOUNT}/d1/database?name=${name}`);
106 const match = databases.find((database: any) => database.name === name);
107 return match ? match.uuid : null;
108};
109
110const ensureD1 = async (name: string): Promise<string> => {
111 const existing = await findD1(name);
112 if (existing) {
113 process.stderr.write(`reusing D1 ${name} (${existing})\n`);
114 return existing;
115 }
116 const created = await cfOk("POST", `/accounts/${ACCOUNT}/d1/database`, {
117 name,
118 });
119 process.stderr.write(`created D1 ${name} (${created.uuid})\n`);
120 return created.uuid;
121};
122
123const ensureSharedBucket = async (): Promise<void> => {
124 const existing = await cf("GET", `/accounts/${ACCOUNT}/r2/buckets/${SHARED_BLOBS_BUCKET}`);
125 if (existing.ok) return;
126 const created = await cf("POST", `/accounts/${ACCOUNT}/r2/buckets`, {
127 name: SHARED_BLOBS_BUCKET,
128 });
129 if (!created.ok) fail(`creating R2 bucket ${SHARED_BLOBS_BUCKET} failed: ${created.errors}`);
130 process.stderr.write(`created shared R2 bucket ${SHARED_BLOBS_BUCKET}\n`);
131};
132
133const workersSubdomain = async (): Promise<string> => {
134 const result = await cfOk("GET", `/accounts/${ACCOUNT}/workers/subdomain`);
135 return result.subdomain;
136};
137
138const findAccessApp = async (name: string): Promise<any | null> => {
139 const apps = await cfList(`/accounts/${ACCOUNT}/access/apps`);
140 return apps.find((app: any) => app.name === name) ?? null;
141};
142
143/**
144 * One Access application per preview, gating its workers.dev hostname at the
145 * edge. `oauth_configuration` must be present AT CREATION for the app to speak
146 * MCP OAuth (401 + resource_metadata challenge with Access as the authorization
147 * server, dynamic client registration for local MCP clients) — without it,
148 * unauthenticated /mcp requests get a browser login redirect instead.
149 */
150const ensureAccessApp = async (
151 name: string,
152 hostname: string,
153 allowedEmails: readonly string[],
154): Promise<{ id: string; aud: string }> => {
155 const existing = await findAccessApp(name);
156 if (existing) {
157 process.stderr.write(`reusing Access app ${name} (aud ${existing.aud.slice(0, 8)}…)\n`);
158 return { id: existing.id, aud: existing.aud };
159 }
160 const app = await cfOk("POST", `/accounts/${ACCOUNT}/access/apps`, {
161 name,
162 type: "self_hosted",
163 domain: hostname,
164 session_duration: "24h",
165 oauth_configuration: {
166 enabled: true,
167 grant: { session_duration: "24h", access_token_lifetime: "15m" },
168 dynamic_client_registration: {
169 enabled: true,
170 allowed_uris: [],
171 allow_any_on_localhost: true,
172 allow_any_on_loopback: true,
173 },
174 },
175 });
176 await cfOk("POST", `/accounts/${ACCOUNT}/access/apps/${app.id}/policies`, {
177 name: `${name}-allowed-emails`,
178 decision: "allow",
179 include: allowedEmails.map((email) => ({ email: { email } })),
180 });
181 process.stderr.write(`created Access app ${name} for ${hostname}\n`);
182 return { id: app.id, aud: app.aud };
183};
184
185/**
186 * Derive the preview wrangler config from the committed one (same bindings,
187 * preview-specific names) so previews always deploy the real config users get.
188 * Each substitution is asserted so config drift fails loudly here instead of
189 * silently deploying previews against the wrong resources.
190 */
191const writePreviewConfig = (worker: string, databaseName: string, databaseId: string): string => {
192 const basePath = resolve(APP_DIR, "wrangler.jsonc");
193 let text = readFileSync(basePath, "utf8");
194 const substitute = (pattern: RegExp, replacement: string): void => {
195 if (!pattern.test(text))
196 fail(`wrangler.jsonc no longer matches ${pattern} — update preview.ts`);
197 text = text.replace(pattern, replacement);
198 };
199 substitute(/"name":\s*"executor-cloudflare"/, `"name": "${worker}"`);
200 substitute(/"database_name":\s*"[^"]*"/, `"database_name": "${databaseName}"`);
201 substitute(/"database_id":\s*"[^"]*"/, `"database_id": "${databaseId}"`);
202 substitute(/"bucket_name":\s*"[^"]*"/, `"bucket_name": "${SHARED_BLOBS_BUCKET}"`);
203 const outPath = resolve(APP_DIR, "wrangler.preview.jsonc");
204 writeFileSync(outPath, `// Generated by scripts/preview.ts — do not edit.\n${text}`);
205 return outPath;
206};
207
208// --- commands ----------------------------------------------------------------
209
210const deploy = async (): Promise<void> => {
211 const pr = prNumber();
212 const worker = workerName(pr);
213 const teamDomain = process.env.PREVIEW_ACCESS_TEAM_DOMAIN ?? "";
214 const allowedEmails = (process.env.PREVIEW_ACCESS_EMAILS ?? "")
215 .split(",")
216 .map((email) => email.trim())
217 .filter((email) => email.length > 0);
218 if (teamDomain.length === 0) fail("PREVIEW_ACCESS_TEAM_DOMAIN is required");
219 if (allowedEmails.length === 0) fail("PREVIEW_ACCESS_EMAILS is required");
220 const adminEmails = process.env.PREVIEW_ADMIN_EMAILS ?? allowedEmails[0]!;
221
222 const databaseId = await ensureD1(worker);
223 await ensureSharedBucket();
224 const subdomain = await workersSubdomain();
225 const hostname = `${worker}.${subdomain}.workers.dev`;
226 const app = await ensureAccessApp(worker, hostname, allowedEmails);
227 const configPath = writePreviewConfig(worker, worker, databaseId);
228
229 // turbo so workspace dependencies with build steps (@executor-js/vite-plugin)
230 // are built first — a fresh checkout has no dist/ anywhere.
231 if (!process.argv.includes("--skip-build")) {
232 run("bunx", ["turbo", "build", "--filter=@executor-js/host-cloudflare"], {
233 cwd: resolve(APP_DIR, "../.."),
234 });
235 }
236 run("bunx", [
237 "wrangler",
238 "deploy",
239 "--config",
240 configPath,
241 "--var",
242 `ACCESS_TEAM_DOMAIN:${teamDomain}`,
243 "--var",
244 `ACCESS_AUD:${app.aud}`,
245 "--var",
246 `ADMIN_EMAILS:${adminEmails}`,
247 ]);
248
249 // The at-rest encryption key: stable across redeploys of the same PR (a new
250 // key would orphan secrets already stored in the preview's D1), fresh per
251 // preview, set after deploy because the Worker must exist first.
252 const secretList = spawnSync("bunx", ["wrangler", "secret", "list", "--config", configPath], {
253 cwd: APP_DIR,
254 encoding: "utf8",
255 env: process.env,
256 });
257 if (!(secretList.stdout ?? "").includes("EXECUTOR_SECRET_KEY")) {
258 run("bunx", ["wrangler", "secret", "put", "EXECUTOR_SECRET_KEY", "--config", configPath], {
259 input: randomBytes(32).toString("hex"),
260 });
261 }
262
263 const url = `https://${hostname}`;
264 process.stderr.write(`preview live at ${url}\n`);
265 process.stdout.write(`${JSON.stringify({ pr, worker, url, mcpUrl: `${url}/mcp` })}\n`);
266 if (process.env.GITHUB_OUTPUT) {
267 appendFileSync(process.env.GITHUB_OUTPUT, `url=${url}\nworker=${worker}\n`);
268 }
269};
270
271const destroy = async (): Promise<void> => {
272 const pr = prNumber();
273 const worker = workerName(pr);
274
275 // force=true also removes the Worker's Durable Objects + their storage.
276 const script = await cf("DELETE", `/accounts/${ACCOUNT}/workers/scripts/${worker}?force=true`);
277 process.stderr.write(
278 script.ok ? `deleted worker ${worker}\n` : `worker ${worker} already gone\n`,
279 );
280
281 const databaseId = await findD1(worker);
282 if (databaseId) {
283 await cfOk("DELETE", `/accounts/${ACCOUNT}/d1/database/${databaseId}`);
284 process.stderr.write(`deleted D1 ${worker}\n`);
285 }
286
287 const app = await findAccessApp(worker);
288 if (app) {
289 await cfOk("DELETE", `/accounts/${ACCOUNT}/access/apps/${app.id}`);
290 process.stderr.write(`deleted Access app ${worker}\n`);
291 }
292};
293
294const list = async (): Promise<void> => {
295 const scripts = await cfList(`/accounts/${ACCOUNT}/workers/scripts`);
296 const previews = scripts
297 .map((script: any) => String(script.id))
298 .filter((id) => new RegExp(`^${WORKER_PREFIX}\\d+$`).test(id))
299 .map((id) => ({ pr: Number(id.slice(WORKER_PREFIX.length)), name: id }))
300 .sort((a, b) => a.pr - b.pr);
301 process.stdout.write(`${JSON.stringify(previews)}\n`);
302};
303
304// --- main --------------------------------------------------------------------
305
306if (TOKEN.length === 0) fail("CLOUDFLARE_API_TOKEN is required");
307if (ACCOUNT.length === 0) fail("CLOUDFLARE_ACCOUNT_ID is required");
308
309const command = process.argv[2];
310if (command === "deploy") await deploy();
311else if (command === "destroy") await destroy();
312else if (command === "list") await list();
313else fail("usage: preview.ts <deploy|destroy|list> [--pr <number>] [--skip-build]");