diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b188340..06fd894c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,9 @@ env: NODE_VERSION: "24.19.0" jobs: - # Parallel to `test` so lint and test failures surface on the same run; type checking lives in Build. + # Parallel to `test` so lint and test failures surface on the same run. Package type checking + # lives in Build; `scripts/` is checked here instead, because it is deliberately kept off + # `pnpm build`'s hot path (see AGENTS.md) and this is the cheap job. lint: name: Lint runs-on: ubuntu-latest @@ -39,6 +41,9 @@ jobs: - name: Lint run: pnpm lint:check + - name: Type-check scripts/ + run: pnpm types:scripts + test: name: Build and test runs-on: ubuntu-latest diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..01b5d03d --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,257 @@ +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. +# +# The `cache: pnpm` below is deliberate and is not a poisoning path: a fork PR's Actions cache is +# scoped to `refs/pull//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 && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) && + 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 + + # Ahead of setup-node, which shells out to `pnpm store path` to find the directory it caches. + - name: Enable Corepack + run: corepack enable + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - 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 Previews settings with + # `wrangler preview 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 Run + Read token are required. + CF_AI_GATEWAY: ${{ secrets.CF_AI_GATEWAY }} + # This is delibaretely set to CF_OS_AI_GATEWAY_ACCOUNT_ID (gets uploaded as CF_AI_GATEWAY_ACCOUNT_ID) + CF_AI_GATEWAY_ACCOUNT_ID: ${{ secrets.CF_OS_AI_GATEWAY_ACCOUNT_ID }} + CF_AI_GATEWAY_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_API_TOKEN }} + CF_AI_GATEWAY_PROVIDERS: ${{ secrets.CF_AI_GATEWAY_PROVIDERS }} + CF_AI_GATEWAY_WAI_DIRECT: ${{ secrets.CF_AI_GATEWAY_WAI_DIRECT }} + # Together these are the preview name, which is the first label of its hostname: a preview + # reads as `pr123-my-branch-router..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: "" + 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 && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) && + 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 + + - name: Enable Corepack + run: corepack enable + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - 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 + + - name: Enable Corepack + run: corepack enable + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - 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 diff --git a/.gitignore b/.gitignore index 7ce4660a..2a81c9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ wrangler.staging.jsonc # Site-specific dev server configs. wrangler.dev.jsonc +# Scratch output from scripts/preview (the PR-preview comment body). +/output/ + # macOS .DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d58f613a..be75c761 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,3 +9,15 @@ With that said, we are happy to accept small, trivially-verified PRs that fix a 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. This policy may change in the future as the project matures. Until then, thank you for your understanding. + +## What CI runs on your pull request + +Lint, build and tests ([`ci.yml`](.github/workflows/ci.yml)) run on every pull request, including +those from forks. + +Preview deployments ([`preview.yml`](.github/workflows/preview.yml)) do **not**, this is +deliberate. Deploying a preview requires a Cloudflare API token that can create Workers +and storage on a Cloudflare-owned account, and GitHub structurally withholds repository secrets +from `pull_request` runs whose head is a fork. If you see the preview job skipped on your PR, +that is working as intended — a maintainer will deploy one if the change needs manual review. +See [`.github/workflows/README.md`](.github/workflows/README.md). diff --git a/package.json b/package.json index f799773b..44890843 100644 --- a/package.json +++ b/package.json @@ -7,17 +7,23 @@ "scripts": { "build": "vp run -r --cache build", "run-local": "node scripts/run-local.mjs", - "test": "node --test scripts/*.test.js && vp run --filter '!cloudflare-os' --cache test", + "test": "node --test 'scripts/**/*.test.js' 'scripts/**/*.test.ts' && vp run --filter '!cloudflare-os' --cache test", + "preview:config": "node scripts/preview/preview.ts config", + "preview:deploy": "node scripts/preview/preview.ts deploy", + "preview:delete": "node scripts/preview/preview.ts delete", + "preview:sweep": "node scripts/preview/preview.ts sweep", "dev-client": "cd packages/workshop-frontend && pnpm run dev", "dev-server": "node run-dev-server.js", "clean": "vp run -r clean", "lint:check": "vp lint", "lint:fix": "vp lint --fix", "types:check": "pnpm run build", - "lint": "pnpm run lint:check && pnpm run types:check", + "types:scripts": "tsc -p scripts/tsconfig.json", + "lint": "pnpm run lint:check && pnpm run types:scripts && pnpm run types:check", "types:generate": "node scripts/generate-worker-types.mjs" }, "devDependencies": { + "@types/node": "26.1.0", "aws4fetch": "^1.0.20", "jsonc-parser": "^3.3.1", "typescript": "catalog:", diff --git a/packages/workshop-backend/src/env.d.ts b/packages/workshop-backend/src/env.d.ts index 404ddb81..e0443836 100644 --- a/packages/workshop-backend/src/env.d.ts +++ b/packages/workshop-backend/src/env.d.ts @@ -6,8 +6,9 @@ import type { ProductAnalyticsRecord } from "./analytics"; declare global { namespace Cloudflare { interface Env { - // Deployment-wide admin usernames. - ADMINS?: string[]; + // Deployment-wide admin usernames: a JSON binding, or the same array as a JSON string + // (which is what a secret binding, can carry). + ADMINS?: string[] | string; // Workers AI binding (injected by generate-wrangler-prod / run-dev-server; not in base wrangler.jsonc). WORKERS_AI: Ai; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f572acfe..9e56f08e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: .: devDependencies: + '@types/node': + specifier: 26.1.0 + version: 26.1.0 aws4fetch: specifier: ^1.0.20 version: 1.0.20 diff --git a/scripts/env-passthrough.test.js b/scripts/env-passthrough.test.js index df88fb06..53a7ab07 100644 --- a/scripts/env-passthrough.test.js +++ b/scripts/env-passthrough.test.js @@ -55,10 +55,16 @@ const EXPECTED = { }, // `build-gatekeeper-configurator.mjs` is covered in detail by // build-gatekeeper-configurator.test.js, which pins its reads against the shared task's `env`. - // `build-release.mjs` and `run-local.mjs` are invoked directly, never as vp tasks. + // `build-release.mjs`, `run-local.mjs` and `preview/` are invoked directly, never as vp tasks. scripts: { forwarded: ["VITE_FRONTEND_ERROR_REPORTING"], - external: ["CI_COMMIT_SHA", "CI_PIPELINE_IID", "VITE_BACKEND_HOST"], + external: [ + "CF_ACCESS_AUD", "CF_ACCESS_ISS", "CF_AI_GATEWAY", "CF_AI_GATEWAY_ACCOUNT_ID", + "CF_AI_GATEWAY_API_TOKEN", "CF_AI_GATEWAY_PROVIDERS", "CF_AI_GATEWAY_WAI_DIRECT", + "CI_COMMIT_SHA", "CI_PIPELINE_IID", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", + "GITHUB_REPOSITORY", "GITHUB_TOKEN", "PREVIEW_ADMINS", "PREVIEW_NAME", + "PREVIEW_PR_NUMBER", "PREVIEW_WORKERS_DEV_HOST", "PREVIEW_WRANGLER", "VITE_BACKEND_HOST", + ], }, }; diff --git a/scripts/preview/preview.ts b/scripts/preview/preview.ts new file mode 100644 index 00000000..13d4878d --- /dev/null +++ b/scripts/preview/preview.ts @@ -0,0 +1,863 @@ +#!/usr/bin/env node + +// Deploys, or tears down, a complete instance as a set of Cloudflare Worker Previews — one per +// pull request — on the account named by CLOUDFLARE_ACCOUNT_ID. +// +// node scripts/preview/preview.ts config regenerate wrangler.staging.jsonc only +// node scripts/preview/preview.ts deploy build + deploy the preview instance +// node scripts/preview/preview.ts delete tear it down +// node scripts/preview/preview.ts sweep tear down every abandoned preview +// ... --dry-run print the plan, touch no network +// +// Deployment runs in three tiers, because each tier's service bindings must name the previews the +// tier before it produced: +// +// 1. the 16 gatekeepers (concurrently; nothing binds to anything) +// 2. workshop-backend (binds every gatekeeper preview via GatekeeperVendor) +// 3. router (binds the backend preview and every gatekeeper preview, and owns the +// public origin: it serves the frontend and proxies /api and +// /gatekeeper/) +// +// Between tiers the next tier's `previews.services[].preview_id` is patched with the ids the +// previous tier returned. Everything else — every URL in the config — is derived up front from +// the router's preview name, which is deterministic, so the tiers only have to exchange ids. +// +// The router is the only one of the eighteen with a hostname. Preview URLs are public, so the +// other seventeen set `preview_urls: false` and are reached over service bindings alone; the +// deploy asserts that, since a URL appearing on one of them is a way around the router. +// +// The backend's secrets — its admins and the Cloudflare Access application that authenticates the +// instance — are uploaded to the worker's Previews settings between tiers 1 and 2 and are never +// written into a config, because Wrangler prints config values and this workflow's logs are public. +// See uploadSecrets, and backendSecrets in staging-config.ts. + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawn } from "node:child_process"; +import { + ROOT, + STAGING_CONFIG_NAME, + backendSecrets, + gatekeeperShortName, + generatePreviewConfigs, + isGatekeeper, + previewPullRequestNumber, + previewUrlFor, + resolvePreviewName, + writePreviewConfig, + type DeployablePackage, + type StagingConfig, +} from "./staging-config.ts"; + +/** The subcommands this script accepts. */ +type Command = "config" | "deploy" | "delete" | "sweep"; + +/** A finished child process: its exit status and the output it produced. */ +interface CommandResult { + /** Exit code, or null if the process was killed by a signal. */ + status: number | null; + /** Everything written to stdout. */ + stdout: string; + /** Everything written to stderr. */ + stderr: string; +} + +/** The Wrangler build a run uses, and how to dispose of it (see WRANGLER_PACKAGE). */ +interface PreviewWrangler { + /** The `wrangler` binary to invoke. */ + command: string; + /** Resolves once the binary is installed and usable. */ + ready: Promise; + /** Removes the temporary install, if there is one. */ + cleanup: () => void; +} + +/** What one deployed Worker Preview reports back. */ +interface DeployedPreview { + /** The preview id a sibling preview's service binding points at. */ + id: string; + /** The preview's slug, as Wrangler named it. */ + slug: string; + /** Its public URL, if it has one. Only the router does. */ + url: string | undefined; + /** Wrangler's raw stdout, kept for diagnostics. */ + output: string; +} + +/** The `--json` payload `wrangler preview` emits. */ +interface WranglerPreviewJson { + preview?: { id?: string; slug?: string; urls?: string[] }; +} + +/** One preview as the Cloudflare API lists it, for the sweep. */ +interface ListedPreview { + /** The preview name: `pr-`, or the bare slug for a local deploy. */ + name: string; + created_on?: string; + created_at?: string; + modified_on?: string; +} + +/** One preview of a given name, as the sweep indexes it across every worker. */ +interface IndexedPreview { + /** The workers that carry it — the ones a teardown has to delete it from. */ + workers: DeployablePackage[]; + /** Age in days of the *newest* copy, or null if none of them reported a timestamp. */ + age: number | null; +} + +/** + * What the sweep knows about a preview's pull request. GitHub reports the first two ("closed" covers + * merged); `missing` is a 404 — a number naming no pull request — and `unknown` is a preview with no + * number in its name, or a request that failed. + */ +type PullRequestState = "open" | "closed" | "missing" | "unknown"; + +/** One pull request as the sweep reads it: whether it is still open. */ +interface FetchedPullRequest { + /** "open", or "closed" — which covers merged. */ + state?: string; +} + +const USAGE = "Usage: preview.ts [--dry-run]"; +const OUTPUT_DIR = join(ROOT, "output"); +const PREVIEW_COMMENT = join(OUTPUT_DIR, "preview-comment.md"); +const GATEKEEPER_CONCURRENCY = 8; +// How many of the sweep's read-only API requests — one preview list per worker, one pull request per +// number — are in flight at once. +const API_CONCURRENCY = 8; + +// Worker Previews are in private beta, and two of the features this script is built on are not in +// any released Wrangler: per-preview resource auto-provisioning (`previews.kv_namespaces` etc. +// declared binding-only), and `preview_id` on a `previews.services` entry, which is what points a +// preview at a *sibling* preview rather than at the baseline worker. Verified 2026-08-16 against +// the pinned 4.120.0 and the then-latest 4.123.0: both accept a binding-only KV entry in the +// schema but send `namespace_id: undefined`, and both silently drop `preview_id` from a service +// binding — which would leave the whole instance wired to the baselines. So the deploy runs on +// the draft build from https://github.com/cloudflare/workers-sdk/pull/14416 instead, installed +// into a tmpdir. It pulls matching workers-sdk workspace packages from pkg.pr.new, hence the +// exotic-subdeps opt-out. +// +// Drop all of this — and set PREVIEW_WRANGLER=pnpm-exec-wrangler in the meantime to check — once +// both features ship: `preview_id` appearing in a released `config-schema.json` under +// `PreviewsConfig.properties.services.items.properties` is the signal. +const WRANGLER_PACKAGE = "https://pkg.pr.new/wrangler@14416"; + +function parseArgs(argv: string[]): { command: Command; dryRun: boolean } { + const command = argv[0] as Command; + if (!["config", "deploy", "delete", "sweep"].includes(command)) throw new Error(USAGE); + const unknown = argv.slice(1).filter((arg) => arg !== "--dry-run"); + if (unknown.length > 0) throw new Error(`unknown argument: ${unknown[0]}\n${USAGE}`); + return { command, dryRun: argv.includes("--dry-run") }; +} + +function runAsync( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): Promise { + console.log(`running: ${command} ${args.join(" ")}`); + return new Promise((resolve, reject) => { + const child = spawn(command, args, { env: process.env, stdio: "inherit", ...options }); + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`)); + }); + }); +} + +// Every item is attempted even after one fails +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + mapper: (item: T, index: number) => Promise, +): Promise { + const results: R[] = Array.from({ length: items.length }); + let nextIndex = 0; + let firstError: unknown; + + async function worker() { + while (nextIndex < items.length) { + const index = nextIndex++; + try { + results[index] = await mapper(items[index], index); + } catch (error) { + if (firstError === undefined) firstError = error; + else console.error(`Additional concurrent failure: ${describe(error)}`); + } + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); + if (firstError !== undefined) throw firstError; + return results; +} + +async function waitForAll(promises: readonly Promise[]): Promise { + const results = await Promise.allSettled(promises); + const failures = results.filter((r) => r.status === "rejected").map((r) => r.reason); + for (const failure of failures.slice(1)) { + console.error(`Additional concurrent failure: ${describe(failure)}`); + } + if (failures.length > 0) throw failures[0]; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// Codegen, the frontend bundle the router serves, and each gatekeeper's SPA bundle. Vite+ caches +// per task, so this is cheap on a warm tree. +// +// Whether the UI signs in through Cloudflare Access or with a password is a build-time flag +// (workshop-frontend/src/useAuth.ts), so a preview needs the same one build-release.mjs sets: +// otherwise it serves a password form the backend rejects every password from. The frontend's +// `build` task already declares `env: ['VITE_*']`. +function buildWorkspace(): Promise { + return runAsync("pnpm", ["run", "build"], + { cwd: ROOT, env: { ...process.env, VITE_CF_ACCESS_MODE: "true" } }); +} + +function preparePreviewWrangler(): PreviewWrangler { + const override = process.env.PREVIEW_WRANGLER; + if (override) { + return { command: override, ready: Promise.resolve(), cleanup: () => {} }; + } + + const installDir = mkdtempSync(join(tmpdir(), "preview-wrangler-")); + try { + writeFileSync(join(installDir, "package.json"), JSON.stringify({ + private: true, + dependencies: { wrangler: WRANGLER_PACKAGE }, + })); + writeFileSync(join(installDir, "pnpm-workspace.yaml"), + "allowBuilds:\n esbuild: true\n sharp: true\n workerd: true\n"); + } catch (error) { + rmSync(installDir, { recursive: true, force: true }); + throw error; + } + + return { + command: join(installDir, "node_modules", ".bin", "wrangler"), + ready: runAsync("pnpm", + ["--config.blockExoticSubdeps=false", "--dir", installDir, "install"]), + cleanup: () => rmSync(installDir, { recursive: true, force: true }), + }; +} + +function readConfig(pkgDir: string): StagingConfig { + return JSON.parse(readFileSync(join(pkgDir, STAGING_CONFIG_NAME), "utf8")) as StagingConfig; +} + +// Wrangler's prose and its `code: 10007`, plus the `"code":10007` the Cloudflare API returns — the +// sweep lists previews over that API, and a worker whose baseline was never deployed has none. +function isMissingWorkerError(output: string): boolean { + return /This Worker does not exist on your account|code"?:\s*10007/i.test(output); +} + +// `wrangler --json` still logs informational text to stdout ahead of the JSON object, but the +// payload is emitted last. +function parseWranglerJson(raw: string): WranglerPreviewJson { + const start = raw.lastIndexOf("\n{"); + const jsonStart = start >= 0 ? start + 1 : raw.indexOf("{"); + if (jsonStart >= 0) { + const parsed = JSON.parse(raw.slice(jsonStart)) as WranglerPreviewJson; + if (parsed.preview) return parsed; + } + throw new Error("Failed to parse wrangler JSON output: " + + (jsonStart < 0 ? "no JSON payload found" : "no preview JSON payload found")); +} + +function runWrangler( + pkg: DeployablePackage, + wranglerCommand: string, + args: string[], + // Written to the child's stdin, which is how secret values are handed over: argv is visible in + // `ps` output and is echoed by the `running:` lines above every invocation. + input?: string, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(wranglerCommand, args, { + cwd: pkg.dir, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + // Closed immediately either way: no command here reads stdin except `secret bulk`, and an + // empty pipe is the same EOF the previous `ignore` produced. + child.stdin.end(input ?? ""); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (data: string) => { stdout += data; }); + child.stderr.on("data", (data: string) => { stderr += data; }); + child.once("error", + (error) => reject(new Error(`Failed to run Wrangler for ${pkg.name}: ${error.message}`))); + child.once("close", (status) => resolve({ status, stdout, stderr })); + }); +} + +function writeCommandOutput(pkg: DeployablePackage, { stdout, stderr }: CommandResult): void { + if (stdout) process.stdout.write(`[${pkg.name}]\n${stdout}`); + if (stderr) process.stderr.write(`[${pkg.name}]\n${stderr}`); +} + +// A Worker Preview is a branch of a baseline worker, so the baseline has to exist. Rather than +// requiring a separate bootstrap, deploy it the first time a preview reports it missing. +async function deployBaselineWorker( + pkg: DeployablePackage, + wranglerCommand: string, +): Promise { + console.log(`baseline worker missing; running in ${pkg.name}: ` + + `wrangler deploy -c ${STAGING_CONFIG_NAME}`); + const result = await runWrangler(pkg, wranglerCommand, ["deploy", "-c", STAGING_CONFIG_NAME]); + writeCommandOutput(pkg, result); + if (result.status !== 0) { + throw new Error( + `wrangler deploy failed for baseline worker ${pkg.name} with exit code ${result.status}`); + } +} + +/** + * Give a worker its secrets — the backend's admins and Cloudflare Access pair (see + * backendSecrets). None of them is in the generated config, because Wrangler prints the values it + * finds there and this workflow's logs are public; `secret bulk` prints only names and `********`, + * and the values arrive on stdin rather than in argv. + * + * `preview secret bulk` writes the *Worker's Previews settings*, which every preview of that worker + * inherits, so one upload covers every preview and each run refreshes them. The plain `secret bulk` + * form is for the baseline worker itself, which is a real, publicly reachable instance and needs + * the same Access application in front of it. + */ +async function uploadSecrets( + pkg: DeployablePackage, + wranglerCommand: string, + secrets: Record, + { previews }: { previews: boolean }, +): Promise { + const args = [...previews ? ["preview"] : [], "secret", "bulk", "-c", STAGING_CONFIG_NAME]; + console.log(`running in ${pkg.name}: wrangler ${args.join(" ")} ` + + `(${Object.keys(secrets).join(", ")} on stdin)`); + const result = await runWrangler(pkg, wranglerCommand, args, JSON.stringify(secrets)); + writeCommandOutput(pkg, result); + return result; +} + +/** + * Upload the backend's secrets, creating its baseline worker first if it does not exist yet. + * + * This runs before the backend's own preview rather than relying on deployPreview's self-heal, + * because a worker has no Previews settings to write to until it exists — and a preview created + * before the settings existed would come up with no admins and, worse, no Access application, so it + * would fall back to password signup on a public URL. + */ +async function uploadBackendSecrets( + backend: DeployablePackage, + wranglerCommand: string, + secrets: Record, +): Promise { + let result = await uploadSecrets(backend, wranglerCommand, secrets, { previews: true }); + if (result.status !== 0 && isMissingWorkerError(`${result.stdout}\n${result.stderr}`)) { + await deployBaselineWorker(backend, wranglerCommand); + // The baseline is briefly live without these, but it is only reachable through the *baseline* + // router — which is deployed after it, in tier 3, on the same first run. + const baseline = await uploadSecrets(backend, wranglerCommand, secrets, { previews: false }); + if (baseline.status !== 0) { + throw new Error(`wrangler secret bulk failed for baseline worker ${backend.name} with exit ` + + `code ${baseline.status}`); + } + result = await uploadSecrets(backend, wranglerCommand, secrets, { previews: true }); + } + if (result.status !== 0) { + throw new Error(`wrangler preview secret bulk failed for ${backend.name} with exit code ` + + `${result.status}`); + } +} + +async function runPreviewCommand( + pkg: DeployablePackage, + previewName: string, + wranglerCommand: string, +): Promise { + console.log(`running in ${pkg.name}: ` + + `wrangler preview --name ${previewName} -c ${STAGING_CONFIG_NAME} --json`); + const result = await runWrangler(pkg, wranglerCommand, + ["preview", "--name", previewName, "-c", STAGING_CONFIG_NAME, "--json"]); + if (result.stderr) process.stderr.write(`[${pkg.name}]\n${result.stderr}`); + return result; +} + +async function deployPreview( + pkg: DeployablePackage, + previewName: string, + wranglerCommand: string, +): Promise { + let result = await runPreviewCommand(pkg, previewName, wranglerCommand); + if (result.status !== 0 && isMissingWorkerError(`${result.stdout}\n${result.stderr}`)) { + await deployBaselineWorker(pkg, wranglerCommand); + result = await runPreviewCommand(pkg, previewName, wranglerCommand); + } + if (result.status !== 0) { + throw new Error(`wrangler preview failed for ${pkg.name} with exit code ${result.status}`); + } + + const data = parseWranglerJson(result.stdout); + // The id is what a sibling preview binds to, so it is required of every worker. A URL is not: + // only the router sets `preview_urls`, and the other seventeen are reached over service + // bindings alone. + if (!data.preview?.id) throw new Error(`Wrangler did not emit a preview id for ${pkg.name}`); + return { + id: data.preview.id, + slug: data.preview.slug ?? previewName, + url: data.preview.urls?.[0], + output: result.stdout, + }; +} + +async function deletePreview( + pkg: DeployablePackage, + previewName: string, + wranglerCommand: string, +): Promise { + console.log(`running in ${pkg.name}: ` + + `wrangler preview delete --name ${previewName} -c ${STAGING_CONFIG_NAME} -y`); + const result = await runWrangler(pkg, wranglerCommand, + ["preview", "delete", "--name", previewName, "-c", STAGING_CONFIG_NAME, "-y"]); + writeCommandOutput(pkg, result); + if (result.status === 0) return; + + // Deleting a preview that was never created — a PR closed before its first deploy finished, a + // re-run of the cleanup job — is the expected case, not a failure. + const output = `${result.stdout}\n${result.stderr}`; + if (/not found|does not exist|10007|10025|10222/i.test(output)) { + console.warn(`Preview ${previewName} for ${pkg.name} did not exist; continuing.`); + return; + } + throw new Error( + `wrangler preview delete failed for ${pkg.name} with exit code ${result.status}`); +} + +/** + * Delete one preview from every worker that carries it. + * + * Dependents first — the router, then the backend, then the rest concurrently — so nothing is left + * bound to a preview that no longer exists. + */ +async function deletePreviewFrom( + workers: readonly DeployablePackage[], + previewName: string, + wranglerCommand: string, +): Promise { + const failures: unknown[] = []; + const attempt = async (pkg: DeployablePackage) => { + try { + await deletePreview(pkg, previewName, wranglerCommand); + } catch (error) { + failures.push(error); + } + }; + + const named = (name: string) => workers.filter((pkg) => pkg.name === name); + for (const pkg of [...named("router"), ...named("workshop-backend")]) await attempt(pkg); + await mapWithConcurrency( + workers.filter((pkg) => !["router", "workshop-backend"].includes(pkg.name)), + GATEKEEPER_CONCURRENCY, attempt); + + for (const failure of failures.slice(1)) { + console.error(`Additional failure deleting ${previewName}: ${describe(failure)}`); + } + if (failures.length > 0) throw failures[0]; +} + +/** + * Rewrite one package's `previews.services[].preview_id` from a map of **worker name** to preview + * id, leaving entries the map doesn't mention alone (the router is patched twice — once for the + * gatekeepers, once for the backend). + */ +function patchPreviewServiceBindings( + pkg: DeployablePackage, + previewIds: Record, +): void { + const config = readConfig(pkg.dir); + const services = config.previews?.services; + if (!config.previews || !Array.isArray(services)) { + throw new Error(`${pkg.name} preview config declares no service bindings to patch`); + } + + const matched = new Set(); + config.previews.services = services.map((service) => { + if (!Object.hasOwn(previewIds, service.service)) return service; + matched.add(service.service); + return { ...service, preview_id: previewIds[service.service] }; + }); + + // An unmatched key means the deployed worker's name and the name in the binding have drifted + // apart. Left unchecked that is silent: the binding keeps no preview_id and resolves to the + // baseline worker, so the instance comes up looking healthy and wired to the wrong code. + const unmatched = Object.keys(previewIds).filter((name) => !matched.has(name)); + if (unmatched.length > 0) { + throw new Error(`${pkg.name} has no service binding for ${unmatched.join(", ")}; its ` + + `bindings name ${services.map((s) => s.service).join(", ")}`); + } + writePreviewConfig(pkg.dir, config); +} + +// Only the router has a URL, and every BASE_URL in the instance was derived from that hostname +// before anything deployed — so a mismatch means the whole preview is misconfigured rather than +// merely oddly named. A worker with no `preview_urls` reports no URL at all, which is expected. +function assertRouterPreviewUrl( + pkg: DeployablePackage, + previewName: string, + workersDevHost: string, + actualUrl: string | undefined, +): asserts actualUrl is string { + const expected = previewUrlFor(pkg.name, previewName, workersDevHost); + if (actualUrl !== expected) { + throw new Error( + `Expected preview URL ${expected} for ${pkg.name}, but Wrangler returned ${actualUrl}`); + } +} + +function assertNoPreviewUrl(pkg: DeployablePackage, actualUrl: string | undefined): void { + if (actualUrl) { + // Preview URLs are public. Only the router should be reachable directly, so one appearing on + // a service-bound worker is a routing hole, not a cosmetic surprise. + throw new Error(`${pkg.name} was given the public preview URL ${actualUrl}, but only the ` + + "router should have one; check that its config still sets preview_urls: false"); + } +} + +function writePreviewComment( + baseUrl: string, + slug: string, + accountId: string | undefined, +): void { + const dashboardUrl = `https://dash.cloudflare.com/${accountId}/workers/services/view/` + + `router/production/previews/${slug}`; + // The slug is the PR number and the branch, so it is worth showing: it is the URL's first label. + const comment = [ + `### Preview: \`${slug}\``, + "", + baseUrl, + "", + `[Dashboard](${dashboardUrl}) · deleted when this PR closes`, + ].join("\n"); + + mkdirSync(dirname(PREVIEW_COMMENT), { recursive: true }); + writeFileSync(PREVIEW_COMMENT, comment + "\n"); + console.log(`\nWrote ${PREVIEW_COMMENT}`); +} + +function tiers(packages: readonly DeployablePackage[]): { + gatekeepers: DeployablePackage[]; + backend: DeployablePackage; + router: DeployablePackage; +} { + const byName = (name: string): DeployablePackage => { + const pkg = packages.find((p) => p.name === name); + if (!pkg) throw new Error(`missing deployable package: ${name}`); + return pkg; + }; + return { + gatekeepers: packages.filter((pkg) => isGatekeeper(pkg.name)) + .toSorted((a, b) => a.name.localeCompare(b.name)), + backend: byName("workshop-backend"), + router: byName("router"), + }; +} + +async function deploy({ dryRun }: { dryRun: boolean }): Promise { + // First, before a single config is written: a missing CF_ACCESS_AUD/CF_ACCESS_ISS has to fail + // here rather than after eighteen previews are live with whatever auth they defaulted to. + const secrets = backendSecrets(); + const { previewName, workersDevHost, baseUrl, packages } = generatePreviewConfigs(); + const { gatekeepers, backend, router } = tiers(packages); + + if (dryRun) { + console.log(`\ndry-run plan for preview "${previewName}" at ${baseUrl}:`); + console.log(` tier 1 (${gatekeepers.length} gatekeepers, concurrently):`); + for (const pkg of gatekeepers) { + console.log(` ${pkg.name} ` + + `(no hostname; served at ${baseUrl}/gatekeeper/${gatekeeperShortName(pkg.name)})`); + } + console.log(` tier 2: ${backend.name} (no hostname; served at ` + + `${baseUrl}/api), bound to the tier 1 previews, holding the ` + + `${Object.keys(secrets).join(", ")} secrets`); + console.log(` tier 3: ${router.name} -> ${baseUrl}, ` + + "bound to every preview above"); + return; + } + + const wrangler = preparePreviewWrangler(); + try { + await waitForAll([wrangler.ready, buildWorkspace()]); + + // Keyed by worker name, because that is what a service binding names. + const gatekeeperPreviews = await mapWithConcurrency(gatekeepers, GATEKEEPER_CONCURRENCY, + async (pkg) => { + const preview = await deployPreview(pkg, previewName, wrangler.command); + assertNoPreviewUrl(pkg, preview.url); + return [pkg.name, preview.id]; + }); + const gatekeeperIds = Object.fromEntries(gatekeeperPreviews); + + patchPreviewServiceBindings(backend, gatekeeperIds); + patchPreviewServiceBindings(router, gatekeeperIds); + // Before the backend's preview, not after: a preview inherits the Previews settings that exist + // when it is created. + await uploadBackendSecrets(backend, wrangler.command, secrets); + const backendPreview = await deployPreview(backend, previewName, wrangler.command); + assertNoPreviewUrl(backend, backendPreview.url); + + patchPreviewServiceBindings(router, { + [backend.name]: backendPreview.id, + }); + const routerPreview = await deployPreview(router, previewName, wrangler.command); + assertRouterPreviewUrl(router, previewName, workersDevHost, routerPreview.url); + + console.log(`\nPreview "${previewName}" is live at ${routerPreview.url}`); + writePreviewComment(routerPreview.url, routerPreview.slug, + readConfig(router.dir).account_id); + } finally { + wrangler.cleanup(); + } +} + +async function remove({ dryRun }: { dryRun: boolean }): Promise { + const previewName = resolvePreviewName(); + // Regenerate rather than assume: `delete` runs in its own CI job with a fresh checkout, and + // wrangler needs a config to know which worker and account the preview belongs to. + const { packages } = generatePreviewConfigs({ previewName }); + const { gatekeepers, backend, router } = tiers(packages); + + if (dryRun) { + console.log(`\ndry-run: would delete preview "${previewName}" for ` + + [router, backend, ...gatekeepers].map((pkg) => pkg.name).join(", ")); + return; + } + + const wrangler = preparePreviewWrangler(); + try { + await wrangler.ready; + await deletePreviewFrom(packages, previewName, wrangler.command); + } finally { + wrangler.cleanup(); + } +} + +// --- sweep ------------------------------------------------------------------------------- +// +// GitHub has no equivalent of GitLab's `environment.auto_stop_in`, and a PR that is force-closed, +// or whose cleanup job failed, leaves an instance behind — with an auto-provisioned KV pair and +// R2 bucket per preview, that leaks. This is the replacement, run nightly. + +const PREVIEW_MAX_AGE_DAYS = 7; + +async function cloudflareApi(path: string): Promise { + if (!process.env.CLOUDFLARE_API_TOKEN) { + throw new Error("CLOUDFLARE_API_TOKEN is required to list previews"); + } + const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { + headers: { Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}` }, + }); + const body = await response.json().catch(() => ({})) as + { success?: boolean; errors?: unknown; result?: unknown }; + if (!response.ok || body.success === false) { + throw new Error(`Cloudflare API GET ${path} failed with ${response.status}: ` + + JSON.stringify(body.errors ?? body)); + } + return body.result; +} + +/** + * One pull request's state. + * + * A 404 is an answer rather than a failure: the number was read out of a live preview's name, so a + * pull request that does not exist means the preview outlived it. Anything else that goes wrong is + * `unknown` — a transient 403 (GitHub's secondary rate limit) or 5xx must never be what deletes a + * preview an open pull request is still using, so those fall back to being judged on age alone. + */ +async function fetchPullRequestState(repo: string, number: number): Promise { + const path = `/repos/${repo}/pulls/${number}`; + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "cloudflare-os-preview-sweep", + }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + + try { + const response = await fetch(`https://api.github.com${path}`, { headers }); + if (response.status === 404) return "missing"; + if (!response.ok) throw new Error(`GitHub API GET ${path} failed with ${response.status}`); + const { state } = await response.json() as FetchedPullRequest; + if (state === "open" || state === "closed") return state; + throw new Error(`GitHub API GET ${path} reported the state ${JSON.stringify(state)}`); + } catch (error) { + console.warn(`${describe(error)}; pull request ${number}'s preview is swept on age alone.`); + return "unknown"; + } +} + +/** + * Every listed preview's pull request state, keyed by preview name. + * + * The number is part of the name ({@link previewPullRequestNumber}), so this is one request per + * *distinct* pull request — no walk of the repository's recent ones, and no bound on how old a pull + * request the sweep can still recognize. + */ +async function pullRequestStates( + repo: string, + previewNames: readonly string[], +): Promise> { + const numbers = [...new Set(previewNames + .map((name) => previewPullRequestNumber(name)) + .filter((value): value is number => value !== undefined))]; + const states = await mapWithConcurrency(numbers, API_CONCURRENCY, + (number) => fetchPullRequestState(repo, number)); + const byNumber = new Map(numbers.map((number, index) => [number, states[index]])); + + return new Map(previewNames.map((name) => { + // A preview with no number in its name was deployed from a local checkout, and is something + // this sweep can say nothing about: age alone decides it. + const number = previewPullRequestNumber(name); + return [name, (number === undefined ? undefined : byNumber.get(number)) ?? "unknown"]; + })); +} + +function daysSince(timestamp: string): number { + return (Date.now() - Date.parse(timestamp)) / 86_400_000; +} + +function ageInDays(preview: ListedPreview): number | null { + const stamp = preview.created_on ?? preview.created_at ?? preview.modified_on; + const age = stamp ? daysSince(stamp) : NaN; + return Number.isNaN(age) ? null : age; +} + +/* Every preview live on any of this checkout's workers, as `name -> the workers carrying it` */ +async function listPreviewsByName( + accountId: string | undefined, + packages: readonly DeployablePackage[], +): Promise> { + const lists = await mapWithConcurrency(packages, API_CONCURRENCY, async (pkg) => { + let previews: unknown; + try { + previews = await cloudflareApi( + `/accounts/${accountId}/workers/workers/${pkg.name}/previews`); + } catch (error) { + // A worker whose baseline has never been deployed on this account — one added since the last + // preview ran — carries no previews rather than being a failure. + if (!isMissingWorkerError(describe(error))) throw error; + console.warn(`${pkg.name} does not exist on this account yet; it holds no previews.`); + previews = []; + } + if (!Array.isArray(previews)) { + throw new Error(`Expected a list of previews for ${pkg.name}, got ` + + `${JSON.stringify(previews)?.slice(0, 200)}`); + } + return previews as ListedPreview[]; + }); + + const index = new Map(); + for (const [position, previews] of lists.entries()) { + for (const preview of previews) { + const age = ageInDays(preview); + const found = index.get(preview.name); + if (!found) { + index.set(preview.name, { workers: [packages[position]], age }); + continue; + } + found.workers.push(packages[position]); + // The newest copy decides the age, so a preview is only old enough to sweep once every + // worker's copy of it is. + if (age !== null && (found.age === null || age < found.age)) found.age = age; + } + } + return index; +} + +// Why this preview should go, or an empty list to keep it. +function staleReasons(preview: IndexedPreview, state: PullRequestState): string[] { + const reasons: string[] = []; + if (preview.age !== null && preview.age > PREVIEW_MAX_AGE_DAYS) { + reasons.push(`${preview.age.toFixed(1)} days old`); + } + + // `unknown` — a preview deployed by hand from a local checkout, or a GitHub request that failed — + // contributes no reason of its own, leaving age to decide. + if (state === "closed") reasons.push("its pull request is closed"); + else if (state === "missing") reasons.push("it names no pull request in this repository"); + return reasons; +} + +async function sweep({ dryRun }: { dryRun: boolean }): Promise { + const { packages } = generatePreviewConfigs(); + const { router } = tiers(packages); + const accountId = readConfig(router.dir).account_id; + const repo = process.env.GITHUB_REPOSITORY; + if (!repo) { + console.warn("GITHUB_REPOSITORY is unset: sweeping on age only, not on PR state."); + } + + const index = await listPreviewsByName(accountId, packages); + console.log(`\n${index.size} preview(s) across ${packages.length} worker(s).`); + const states = repo + ? await pullRequestStates(repo, [...index.keys()]) + : new Map(); + + const stale: { name: string; workers: DeployablePackage[]; reasons: string[] }[] = []; + for (const [name, preview] of index) { + const reasons = staleReasons(preview, states.get(name) ?? "unknown"); + if (reasons.length > 0) stale.push({ name, workers: preview.workers, reasons }); + else console.log(` keeping ${name} (on ${preview.workers.map((pkg) => pkg.name).join(", ")})`); + } + + if (stale.length === 0) { + console.log("Nothing to sweep."); + return; + } + for (const { name, workers, reasons } of stale) { + console.log(` ${dryRun ? "dry-run: would delete" : "deleting"} ${name} (${reasons.join(", ")}) ` + + `from ${workers.map((pkg) => pkg.name).join(", ")}`); + } + if (dryRun) return; + + const wrangler = preparePreviewWrangler(); + const failed: string[] = []; + try { + await wrangler.ready; + // Delete is keyed on `--name` alone; the generated configs only supply the worker and account + // to target, so one set of them serves every preview name being swept. + for (const { name, workers } of stale) { + try { + await deletePreviewFrom(workers, name, wrangler.command); + } catch (error) { + // Each preview is independent, and one left behind is a KV pair and an R2 bucket leaked + // until tomorrow night, so the rest still go. + failed.push(name); + console.error(`Failed to delete ${name}: ${describe(error)}`); + } + } + } finally { + wrangler.cleanup(); + } + console.log(`\nSwept ${stale.length - failed.length} of ${stale.length} preview(s).`); + if (failed.length > 0) throw new Error(`failed to delete ${failed.join(", ")}`); +} + +try { + const args = parseArgs(process.argv.slice(2)); + if (!existsSync(join(ROOT, "packages", "workshop-backend", "wrangler.jsonc"))) { + throw new Error("run this from a full checkout: packages/workshop-backend is missing"); + } + + if (args.command === "config") generatePreviewConfigs(); + else if (args.command === "deploy") await deploy(args); + else if (args.command === "delete") await remove(args); + else await sweep(args); +} catch (error) { + console.error(describe(error)); + process.exit(1); +} diff --git a/scripts/preview/staging-config.test.ts b/scripts/preview/staging-config.test.ts new file mode 100644 index 00000000..8c8d9c42 --- /dev/null +++ b/scripts/preview/staging-config.test.ts @@ -0,0 +1,438 @@ +// The pure half of the PR-preview generator (scripts/preview/staging-config.ts): how a preview +// name is derived, and the shape of the configs built from the repo's REAL wrangler.jsonc files. +// Deploying them is not covered here — that needs an account. +// +// Same spirit as release-manifest.test.js: adding a deployable package, or changing how one is +// bound, has to be a conscious decision rather than something a preview silently drops. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + MAX_PREVIEW_NAME_LENGTH, + R2_MAX_BUCKET_NAME_LENGTH, + backendSecrets, + buildPreviewConfigs, + gatekeeperBindingName, + gatekeeperShortName, + isGatekeeper, + previewPullRequestNumber, + readPackages, + resolveAccess, + resolveAiGateway, + resolvePreviewName, + resolveTarget, + routerPreviewUrl, + slugifyPreviewName, + type BindingDecl, + type PreviewOverrides, + type StagingConfig, +} from "./staging-config.ts"; + +const PREVIEW_NAME = "pr-123"; +// The real account is deployment configuration, not something this repository holds, so the +// tests pick their own — which also keeps them from passing only against one account's values. +const ACCOUNT_ID = "0".repeat(32); +const WORKERS_DEV_HOST = "example.workers.dev"; +const BASE_URL = routerPreviewUrl(PREVIEW_NAME, WORKERS_DEV_HOST); +const ADMIN = "someone@example.com"; +const ACCESS = { aud: "a".repeat(64), iss: "https://previews-example.cloudflareaccess.com" }; +const AI_GATEWAY = { + gateway: "example-gateway", + accountId: "1".repeat(32), + apiToken: "example-run-and-read-token", + providers: "cloudflare", + waiDirect: "true", +}; +// Every input is passed explicitly: each resolver defaults to reading the environment, so a machine +// with any of these set would otherwise change what the tests assert. +const SECRETS = backendSecrets({ + admins: [ADMIN], + access: ACCESS, + aiGateway: resolveAiGateway(AI_GATEWAY), +}); + +/** + * Every resource binding declared anywhere in the generated configs, as `[where, resource]`. + * + * A config declares resources in two independent halves — the baseline at the top level, and the + * per-preview overrides under `previews` — across several keys, so flattening them into one + * labelled sequence lets a caller assert per resource instead of nesting to reach one. The two + * halves are listed out rather than indexed by one shared key list, because `d1_databases` is a + * baseline-only key. + */ +function* declaredResources( + configs: Map, +): Generator<[string, BindingDecl]> { + for (const [name, config] of configs) { + const previews = config.previews ?? {}; + // The binding lists whose entries Wrangler provisions when they carry nothing but a binding. + const halves: [string, Record][] = [ + ["baseline", { + kv_namespaces: config.kv_namespaces, + r2_buckets: config.r2_buckets, + d1_databases: config.d1_databases, + worker_loaders: config.worker_loaders, + }], + ["preview", { + kv_namespaces: previews.kv_namespaces, + r2_buckets: previews.r2_buckets, + worker_loaders: previews.worker_loaders, + }], + ]; + for (const [half, lists] of halves) { + for (const [key, resources] of Object.entries(lists)) { + for (const resource of resources ?? []) yield [`${name} ${half} ${key}`, resource]; + } + } + } +} + +/** One generated config's per-preview overrides, asserted present. */ +function previewsOf(configs: Map, name: string): PreviewOverrides { + const config = configs.get(name); + assert.ok(config, `${name}: no preview config`); + assert.ok(config.previews, `${name}: no previews block`); + return config.previews; +} + +function buildAll() { + const packages = readPackages(); + const configs = buildPreviewConfigs({ + previewName: PREVIEW_NAME, + packages, + accountId: ACCOUNT_ID, + workersDevHost: WORKERS_DEV_HOST, + }); + return { packages, configs }; +} + +test("preview names are slugified", () => { + assert.equal(slugifyPreviewName("pr-123"), "pr-123"); + // Uppercase, a slash and an underscore, in a ref short enough to survive the cap intact. + assert.equal(slugifyPreviewName("maximo/Add_Preview"), "maximo-add-preview"); + assert.equal(slugifyPreviewName("--trailing--"), "trailing"); + // A ref with nothing slug-legal in it still has to produce a usable name. + assert.equal(slugifyPreviewName("///"), "preview"); +}); + +test("over-long preview names truncate to a stable hashed form", () => { + const long = "renovate/a-very-long-dependency-branch-name-that-keeps-going"; + const slug = slugifyPreviewName(long); + + // Wrangler derives the preview R2 bucket name from this, so the cap is load-bearing. + assert.ok(slug.length <= MAX_PREVIEW_NAME_LENGTH, `${slug} is ${slug.length} chars`); + assert.match(slug, /^renovate-a-very-lon-[0-9a-f]{8}$/); + assert.equal(slug, slugifyPreviewName(long), "the hash must be stable across runs"); + assert.notEqual(slug, slugifyPreviewName(`${long}-2`), "distinct refs must not collide"); +}); + +test("the preview name cap leaves every provisioned bucket inside R2's limit", () => { + const { configs } = buildAll(); + const longest = "x".repeat(MAX_PREVIEW_NAME_LENGTH); + + for (const config of configs.values()) { + for (const { binding } of config.previews?.r2_buckets ?? []) { + // What Wrangler names the bucket it provisions for a preview. + const name = `${config.name}-${longest}-${binding.toLowerCase().replaceAll("_", "-")}`; + assert.ok(name.length <= R2_MAX_BUCKET_NAME_LENGTH, + `${name} is ${name.length} characters; lower MAX_PREVIEW_NAME_LENGTH`); + } + } +}); + +test("PREVIEW_NAME is what CI passes, and it wins over the local git fallback", () => { + assert.equal(resolvePreviewName({ name: "pr-42", prNumber: "" }), "pr-42"); + assert.equal(resolvePreviewName({ name: "PR/42", prNumber: "" }), "pr-42"); +}); + +test("the preview name carries the pull request number, so look-alike branches cannot collide", () => { + // All three slugify identically, which is the collision the number exists to prevent: without it + // two open pull requests would deploy over each other, and either closing would delete both. + for (const ref of ["feature/foo", "feature-foo", "Feature_Foo"]) { + assert.equal(slugifyPreviewName(ref), "feature-foo"); + } + assert.equal(resolvePreviewName({ name: "feature/foo", prNumber: "241" }), "pr241-feature-foo"); + assert.equal(resolvePreviewName({ name: "Feature_Foo", prNumber: "242" }), "pr242-feature-foo"); + + // No number is a local run, which keeps the bare slug; neither is anything that is not one, since + // the workflow interpolates the empty string on a scheduled run. + assert.equal(resolvePreviewName({ name: "feature/foo", prNumber: "" }), "feature-foo"); + assert.equal(resolvePreviewName({ name: "feature/foo", prNumber: "not-a-number" }), + "feature-foo"); +}); + +test("the pull request number stays inside the name cap, and reads back out of it", () => { + const long = "renovate/a-very-long-dependency-branch-name-that-keeps-going"; + const name = resolvePreviewName({ name: long, prNumber: "12345" }); + + // The cap bounds the whole name, prefix included: Wrangler derives the preview's R2 bucket name + // from it, so a prefix that ate into no budget would be a bucket over R2's limit. + assert.ok(name.length <= MAX_PREVIEW_NAME_LENGTH, `${name} is ${name.length} chars`); + assert.equal(previewPullRequestNumber(name), 12345); + assert.equal(previewPullRequestNumber("pr241-feature-foo"), 241); + // The sweep asks GitHub about whatever it reads back, so a preview deployed without a number has + // to be distinguishable rather than matching some pull request by accident. + assert.equal(previewPullRequestNumber("feature-foo"), undefined); + assert.equal(previewPullRequestNumber("pr-123"), undefined); + assert.equal(previewPullRequestNumber("prefix-123-branch"), undefined); +}); + +test("a reserved prefix shrinks the slug budget rather than overflowing it", () => { + const long = "renovate/a-very-long-dependency-branch-name-that-keeps-going"; + for (const reserve of [0, 3, 9]) { + const slug = slugifyPreviewName(long, { reserve }); + assert.ok(slug.length <= MAX_PREVIEW_NAME_LENGTH - reserve, + `${slug} is ${slug.length} chars with ${reserve} reserved`); + } + // A ref short enough to survive intact is untouched: the budget only bites when it truncates. + assert.equal(slugifyPreviewName("feature/foo", { reserve: 9 }), "feature-foo"); +}); + +test("every deployable package gets a preview config, on the configured account", () => { + const { packages, configs } = buildAll(); + + assert.equal(configs.size, packages.length); + assert.ok(configs.has("router") && configs.has("workshop-backend"), + "the router and backend are the two non-gatekeeper deployables"); + for (const [name, config] of configs) { + assert.equal(config.account_id, ACCOUNT_ID, name); + assert.equal(config.routes, undefined, `${name}: a preview cannot be served from a zone`); + assert.ok(config.previews, `${name}: no previews block`); + // The worker is deployed under its package name; every URL and service binding is derived + // from that, so a divergence here misconfigures the topology rather than merely renaming. + assert.equal(config.name, name, `${name}: worker name diverges from its package`); + } +}); + +test("the router is the only worker with a public hostname", () => { + const { configs } = buildAll(); + + for (const [name, config] of configs) { + const exposed = name === "router"; + // Preview URLs are public, and everything but the router is reached over a service binding. + // Giving one to the backend would publish /api with the router bypassed. + assert.equal(config.workers_dev, exposed, `${name}: workers_dev`); + assert.equal(config.preview_urls, exposed, `${name}: preview_urls`); + } +}); + +test("the backend's per-preview resources carry no ids, so wrangler provisions them", () => { + const { configs } = buildAll(); + const previews = previewsOf(configs, "workshop-backend"); + + assert.deepEqual(previews.kv_namespaces, [{ binding: "BLUEPRINTS" }, { binding: "AVATARS" }]); + assert.deepEqual(previews.r2_buckets, [{ binding: "BLUEPRINT_CONTENT" }]); + assert.deepEqual(previews.worker_loaders, [{ binding: "LOADER" }]); + assert.deepEqual(previews.ai, { binding: "WORKERS_AI" }); + assert.deepEqual(previews.browser, { binding: "BROWSER" }); + + // An id or bucket name here would point the preview at the shared baseline resource, so every + // preview would read and write one another's blueprints. + for (const resource of [...(previews.kv_namespaces ?? []), ...(previews.r2_buckets ?? [])]) { + assert.deepEqual(Object.keys(resource), ["binding"], JSON.stringify(resource)); + } +}); + +test("no config names a resource belonging to another deployment", () => { + // The committed wrangler.jsonc files name resources for the deployment they were written for: + // a real `bucket_name`, and KV entries whose only id is a local-dev Miniflare `preview_id`. + // Passing either through would bind a baseline to that deployment's live bucket on the account + // we share with it, or leave a KV binding with no id at all. Both halves of every config are + // therefore binding-only, and Wrangler provisions. + for (const [where, resource] of declaredResources(buildAll().configs)) { + assert.deepEqual(Object.keys(resource), ["binding"], `${where}: ${JSON.stringify(resource)}`); + } +}); + +test("the backend is told the router's origin, and nothing else", () => { + const { configs } = buildAll(); + const vars = previewsOf(configs, "workshop-backend").vars; + assert.ok(vars, "the backend preview declares no vars"); + + // The origin is the only value the backend needs that is safe to write into a config Wrangler + // will print; its admins and Access pair are uploaded as secrets instead (below). + assert.deepEqual(Object.keys(vars), ["PUBLIC_BASE_URL"]); + assert.equal(vars.PUBLIC_BASE_URL, BASE_URL); + // Setting CF_ACCESS_AUD closes the password path on its own — login() and createAccount() both + // throw once it is set — so this stays unset. + assert.equal(vars.DISABLE_PASSWORD_AUTH, undefined); +}); + +test("the backend's secrets are the admin list, the Access pair and the AI gateway", () => { + // A secret is always text, so the admin list travels as JSON — the form `#isAdmin()` already + // parses. Everything here is uploaded by preview.ts, never written to a config. + assert.deepEqual(SECRETS, { + ADMINS: `["${ADMIN}"]`, + CF_ACCESS_AUD: ACCESS.aud, + CF_ACCESS_ISS: ACCESS.iss, + CF_AI_GATEWAY: AI_GATEWAY.gateway, + CF_AI_GATEWAY_ACCOUNT_ID: AI_GATEWAY.accountId, + CF_AI_GATEWAY_API_TOKEN: AI_GATEWAY.apiToken, + CF_AI_GATEWAY_PROVIDERS: AI_GATEWAY.providers, + CF_AI_GATEWAY_WAI_DIRECT: AI_GATEWAY.waiDirect, + }); +}); + +test("the AI gateway is optional as a group, but not half-configured", () => { + // No gateway is a BYOK preview, which is a working deployment rather than a broken one. + assert.deepEqual(resolveAiGateway({}), {}); + assert.deepEqual(resolveAiGateway({ accountId: AI_GATEWAY.accountId }), {}, "orphans are ignored"); + + // With one, the account and token are what AiGatewayConfig demands: without them it throws on the + // first chat, so the deploy has to be the thing that fails instead. + assert.throws(() => resolveAiGateway({ gateway: "g" }), + /CF_AI_GATEWAY_ACCOUNT_ID and CF_AI_GATEWAY_API_TOKEN must be set when CF_AI_GATEWAY is/); + assert.throws(() => resolveAiGateway({ gateway: "g", apiToken: "t" }), + /CF_AI_GATEWAY_ACCOUNT_ID must be set/); + assert.throws(() => resolveAiGateway({ gateway: "g", accountId: "a" }), + /CF_AI_GATEWAY_API_TOKEN must be set/); + + // The two knobs below the required trio are each independently optional. + assert.deepEqual(resolveAiGateway({ gateway: "g", accountId: "a", apiToken: "t" }), + { CF_AI_GATEWAY: "g", CF_AI_GATEWAY_ACCOUNT_ID: "a", CF_AI_GATEWAY_API_TOKEN: "t" }); +}); + +test("no generated config declares a secret's variable", () => { + for (const [name, config] of buildAll().configs) { + const halves: [string, Record | undefined][] = [ + ["baseline", config.vars], + ["preview", config.previews?.vars], + ]; + for (const [half, vars] of halves) { + for (const key of Object.keys(SECRETS)) { + assert.ok(!Object.hasOwn(vars ?? {}, key), + `${name} ${half} vars declare ${key}; upload it as a secret from preview.ts instead`); + } + } + } +}); + +test("no generated config carries a secret's value anywhere", () => { + // This is the guard that keeps the values out of a public log. Wrangler prints the value of every + // plain-text var in its deploy summary (truncated to about forty characters) and every + // `unsafe.bindings` entry verbatim in its configuration warning, and GitHub's secret masking does + // not save either: it replaces exact occurrences of a registered secret, and a reformatted or + // truncated slice is not one. So no generated config may carry one at all — not in `vars`, not + // anywhere else, and not in any of the eighteen, since only the backend reads them. + // Every value that identifies this deployment — the two whose values are ordinary words are left + // to the name check above. The bare email as well as its JSON form, so that seeding the admins as + // anything other than the secret's exact encoding — a comma-joined var, say — is caught too. + const generic = new Set(["CF_AI_GATEWAY_PROVIDERS", "CF_AI_GATEWAY_WAI_DIRECT"]); + const sensitive = [ + ...Object.entries(SECRETS).filter(([key]) => !generic.has(key)), + ["an admin's email", ADMIN], + ]; + for (const [name, config] of buildAll().configs) { + const serialized = JSON.stringify(config); + for (const [key, value] of sensitive) { + assert.ok(!serialized.includes(value), + `${name}'s config carries ${key}; upload it as a secret from preview.ts instead`); + } + } +}); + +test("every gatekeeper is bound to the backend by RPC and to the router by HTTP", () => { + const { packages, configs } = buildAll(); + const gatekeepers = packages.map((pkg) => pkg.name).filter(isGatekeeper).toSorted(); + assert.ok(gatekeepers.length >= 16, `only found ${gatekeepers.length} gatekeepers`); + + // A service binding names a worker, which is the package name; the binding name — what the + // router and the backend actually scan for — is the uppercased form. + const backend = previewsOf(configs, "workshop-backend").services; + assert.ok(backend, "the backend preview declares no service bindings"); + assert.deepEqual(backend.map((service) => service.service), + gatekeepers); + for (const [index, service] of backend.entries()) { + assert.equal(service.binding, gatekeeperBindingName(gatekeepers[index])); + assert.equal(service.entrypoint, "GatekeeperVendor", service.service); + } + + const router = previewsOf(configs, "router").services; + assert.ok(router, "the router preview declares no service bindings"); + assert.deepEqual(router.map((service) => service.service), + ["workshop-backend", ...gatekeepers], + "the router fronts the backend and every gatekeeper"); + for (const [index, service] of router.slice(1).entries()) { + assert.equal(service.binding, gatekeeperBindingName(gatekeepers[index])); + // The router forwards whole HTTP requests, so it binds the default entrypoint. + assert.equal(service.entrypoint, undefined, service.service); + } +}); + +test("every gatekeeper is mounted under the router's origin", () => { + const { packages, configs } = buildAll(); + + for (const { name } of packages.filter((pkg) => isGatekeeper(pkg.name))) { + assert.equal(previewsOf(configs, name).vars?.BASE_URL, + `${BASE_URL}/gatekeeper/${gatekeeperShortName(name)}`); + } + // The router discovers gatekeepers by lowercasing its GATEKEEPER_* bindings, so the path each + // gatekeeper is told to serve has to be the one the router will route to it. + assert.equal(gatekeeperBindingName("gatekeeper-mcp-portal"), "GATEKEEPER_MCP_PORTAL"); + assert.equal(gatekeeperShortName("gatekeeper-mcp-portal"), "mcp-portal"); +}); + +test("each preview's context collections are namespaced to that preview", () => { + const { configs } = buildAll(); + const context = previewsOf(configs, "workshop-backend").services + ?.find((service) => service.service === "gatekeeper-context"); + assert.ok(context, "the backend has no gatekeeper-context binding"); + + // Previews share one baseline gatekeeper-context worker; the sharingDomain is the only thing + // keeping one PR's collections out of another's. + assert.deepEqual(context.props, { sharingDomain: BASE_URL }); +}); + +test("a worker whose name diverges from its package directory is rejected", () => { + assert.throws(() => buildPreviewConfigs({ + previewName: PREVIEW_NAME, + accountId: ACCOUNT_ID, + workersDevHost: WORKERS_DEV_HOST, + packages: [{ name: "gatekeeper-github", config: { name: "github" } }], + }), /requires them to match/); +}); + +test("an unrecognized deployable package is rejected rather than half-configured", () => { + assert.throws(() => buildPreviewConfigs({ + previewName: PREVIEW_NAME, + accountId: ACCOUNT_ID, + workersDevHost: WORKERS_DEV_HOST, + packages: [{ name: "workshop-frontend", config: { name: "workshop-frontend" } }], + }), /cannot build a preview config/); +}); + +test("the target account has no default, in either direction", () => { + // Defaulting would mean a missing variable deploys somewhere plausible-but-wrong rather than + // failing, and the two have to move together: BASE_URL comes from the host, the workers from + // the account. + assert.deepEqual( + resolveTarget({ accountId: "abc", workersDevHost: "x.workers.dev" }), + { accountId: "abc", workersDevHost: "x.workers.dev" }); + assert.throws(() => resolveTarget({ accountId: "", workersDevHost: "" }), + /CLOUDFLARE_ACCOUNT_ID and PREVIEW_WORKERS_DEV_HOST/); + assert.throws(() => resolveTarget({ accountId: "abc", workersDevHost: "" }), + /PREVIEW_WORKERS_DEV_HOST must be set/); + assert.throws(() => resolveTarget({ accountId: "", workersDevHost: "x.workers.dev" }), + /CLOUDFLARE_ACCOUNT_ID must be set/); + + assert.throws(() => buildPreviewConfigs({ + previewName: PREVIEW_NAME, + accountId: ACCOUNT_ID, + // The signature requires it; the runtime guard is what this asserts, for the caller who + // resolved only half its target. + workersDevHost: undefined as unknown as string, + packages: [], + }), /needs both accountId and workersDevHost/); +}); + +test("the Access application has no default, in either direction", () => { + // A preview is a public workers.dev URL holding real gatekeeper capabilities. With neither value + // set the backend falls back to password signup *silently*, so this has to fail closed — and the + // pair moves together, since an audience without an issuer cannot be verified and an issuer + // without an audience verifies nothing in particular. + assert.deepEqual(resolveAccess(ACCESS), ACCESS); + assert.throws(() => resolveAccess({ aud: "", iss: "" }), + /CF_ACCESS_AUD and CF_ACCESS_ISS/); + assert.throws(() => resolveAccess({ aud: ACCESS.aud, iss: "" }), /CF_ACCESS_ISS must be set/); + assert.throws(() => resolveAccess({ aud: "", iss: ACCESS.iss }), /CF_ACCESS_AUD must be set/); +}); diff --git a/scripts/preview/staging-config.ts b/scripts/preview/staging-config.ts new file mode 100644 index 00000000..c088cba2 --- /dev/null +++ b/scripts/preview/staging-config.ts @@ -0,0 +1,758 @@ +#!/usr/bin/env node + +// Generates a `wrangler.staging.jsonc` next to every deployable package's `wrangler.jsonc`, +// describing that worker as it runs in a per-PR **Worker Preview**. The generated files are +// gitignored (.gitignore) — they are build output, regenerated on every `preview.ts` +// invocation. +// +// This is the preview-side counterpart of scripts/release/manifest-lib.mjs: same 18 deployable +// packages, same binding topology, but resolved to concrete staging values instead of the +// manifest's `$PLACEHOLDER` templates. Where the deploy service would substitute +// `$PUBLIC_BASE_URL`, a preview substitutes the router preview's workers.dev URL. +// +// Each worker's config has two halves: +// - the top level, which describes the *baseline* worker (`wrangler deploy`). Worker Previews +// are branches of a baseline worker, so one must exist before a preview can be created; +// preview.ts deploys it on demand the first time (see its isMissingWorkerError self-heal). +// - the `previews` block, which describes the per-preview overrides (`wrangler preview`). +// This is where resources are declared *binding-only* so Wrangler provisions a fresh KV +// namespace / R2 bucket per preview, and where service bindings get patched to point at +// sibling previews rather than the baselines. +// +// Gatekeeper OAuth app credentials (CLIENT_ID/CLIENT_SECRET) are deliberately absent: previews +// exercise routing, auth and the agent, not third-party connector flows. + +import { writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +// The release manifest generator is still JS, so the shapes it also reads are declared below +// rather than imported. When it becomes TypeScript they belong there, shared by both generators. +import { findDeployablePackages, readWranglerConfig } from "../release/manifest-lib.mjs"; + +/** A `{ binding: "NAME" }`-shaped wrangler binding declaration. */ +export interface BindingDecl { + binding: string; +} + +/** A service binding declaration in a wrangler config. */ +export interface ServiceBinding { + /** Binding name the calling worker reads. */ + binding: string; + /** Name of the worker being called. */ + service: string; + /** RPC entrypoint on the target, when the caller uses one rather than plain HTTP. */ + entrypoint?: string; + /** Props handed to the target worker on every call. */ + props?: Record; +} + +/** Workers observability settings. */ +export interface ObservabilityConfig { + /** Whether observability is on. */ + enabled: boolean; + /** Fraction of requests sampled. */ + head_sampling_rate?: number; + /** Nested log settings. */ + logs?: { + enabled?: boolean; + invocation_logs?: boolean; + head_sampling_rate?: number; + }; +} + +/** + * The subset of a deployable worker's `wrangler.jsonc` this generator reads. Deliberately partial: + * every other key is carried through untouched by the `structuredClone` in + * {@link buildPreviewConfigs}. + */ +export interface WranglerConfig { + /** Worker name as deployed by `wrangler deploy` from this package. */ + name?: string; + /** Workers observability settings; previews turn invocation logs on. */ + observability?: ObservabilityConfig; + /** KV namespace bindings. Stripped to binding-only so Wrangler provisions per preview. */ + kv_namespaces?: BindingDecl[]; + /** R2 bucket bindings. Stripped to binding-only, as above. */ + r2_buckets?: BindingDecl[]; + /** Worker Loader bindings (the Gadget sandbox). */ + worker_loaders?: BindingDecl[]; + /** Service bindings; rewritten to name sibling previews. */ + services?: ServiceBinding[]; + /** Browser Rendering binding (Gadget PDF exports). */ + browser?: BindingDecl; + /** Artifacts binding — closed beta. */ + artifacts?: BindingDecl; + /** Plain-text vars, extended with each worker's preview-specific values. */ + vars?: Record; +} + +/** + * A service binding in a preview config. `preview_id` is what points a binding at a *sibling* + * preview rather than at the baseline worker; preview.ts patches it in between deployment tiers. + */ +export interface PreviewService extends ServiceBinding { + /** The sibling preview to bind to, instead of the baseline worker of the same name. */ + preview_id?: string; +} + +/** + * The `previews` block: the per-preview overrides `wrangler preview` applies on top of the + * baseline worker. Resource lists here are declared binding-only, which is how Wrangler is told to + * auto-provision a fresh resource per preview. + */ +export interface PreviewOverrides { + /** Observability settings; previews turn invocation logs on. */ + observability?: ObservabilityConfig; + /** Plain-text vars for the preview. */ + vars?: Record; + /** Service bindings, each pointed at a sibling preview once its id is known. */ + services?: PreviewService[]; + /** KV namespaces to auto-provision per preview. */ + kv_namespaces?: BindingDecl[]; + /** R2 buckets to auto-provision per preview. */ + r2_buckets?: BindingDecl[]; + /** Worker Loader bindings (the Gadget sandbox). */ + worker_loaders?: BindingDecl[]; + /** Workers AI binding. */ + ai?: BindingDecl; + /** Browser Rendering binding. */ + browser?: BindingDecl; + /** Artifacts binding (closed beta). */ + artifacts?: BindingDecl; + /** Passed through untouched from the committed config. */ + unsafe?: unknown; +} + +/** + * A generated `wrangler.staging.jsonc`: everything a committed wrangler.jsonc carries, plus the + * keys that exist only for a preview deployment. + */ +export interface StagingConfig extends WranglerConfig { + /** The Cloudflare account previews deploy to. */ + account_id?: string; + /** Whether the worker gets a workers.dev hostname. Only the router does. */ + workers_dev?: boolean; + /** Whether previews of this worker get public URLs. Only the router's do. */ + preview_urls?: boolean; + /** Zone routes. Always deleted — the preview account has no zone. */ + routes?: unknown[]; + /** D1 bindings. None today; stripped alongside the other resource lists. */ + d1_databases?: BindingDecl[]; + /** Workers AI binding, injected for the backend. */ + ai?: BindingDecl; + /** Passed through untouched from the committed config. */ + unsafe?: unknown; + /** The per-preview overrides. */ + previews?: PreviewOverrides; +} + +/** A deployable package as the generator sees it: its name and its parsed wrangler.jsonc. */ +export interface PackageConfig { + /** The workspace package directory name, which is also the worker name. */ + name: string; + /** The parsed wrangler.jsonc. */ + config: WranglerConfig; +} + +/** A deployable package on disk, as {@link readPackages} returns it. */ +export interface DeployablePackage extends PackageConfig { + /** Absolute path to the package directory. */ + dir: string; +} + +/** The values every `apply*` function derives its config from. */ +interface PreviewContext { + /** The preview's public origin: the router preview's workers.dev URL. */ + baseUrl: string; + /** Every gatekeeper package name, sorted. */ + gatekeepers: string[]; +} + +/** The repository root. */ +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** The directory holding every deployable package. */ +export const PACKAGES_DIR = join(ROOT, "packages"); + +/** The generated per-package config file name (gitignored — it is build output). */ +export const STAGING_CONFIG_NAME = "wrangler.staging.jsonc"; + +/** R2's limit on a bucket name, which is what bounds a preview name. */ +export const R2_MAX_BUCKET_NAME_LENGTH = 63; + +/** + * How long a preview name may be. Wrangler names an auto-provisioned bucket + * `--`, so the longest worker and binding in the workspace + * are what this has to leave room for — today the backend's `blueprint-content`, with 28 to + * spare. Not derived from that pair, because the next worker or binding would silently outgrow + * the arithmetic: assertBucketNamesFit checks the number against the generated configs instead. + */ +export const MAX_PREVIEW_NAME_LENGTH = 28; +const PREVIEW_NAME_HASH_LENGTH = 8; + +const GATEKEEPER_PREFIX = "gatekeeper-"; + +/** True for the packages that are gatekeeper workers (as opposed to the router and backend). */ +export function isGatekeeper(pkgName: string): boolean { + return pkgName.startsWith(GATEKEEPER_PREFIX); +} + +/** + * The vendor id a gatekeeper is reached by: `gatekeeper-mcp-portal` -> `mcp-portal`. Matches + * manifest-lib.mjs's shortName, and hence the `/gatekeeper/` path the router serves. + */ +export function gatekeeperShortName(pkgName: string): string { + return pkgName.slice(GATEKEEPER_PREFIX.length); +} + +/** + * The service binding name a gatekeeper is bound as: `gatekeeper-mcp-portal` -> + * `GATEKEEPER_MCP_PORTAL`. Both the router (router/src/index.ts) and the backend + * (buildGatekeeperVendorMap) discover gatekeepers by scanning for this prefix, so the name is + * the wiring — nothing references a specific gatekeeper. + */ +export function gatekeeperBindingName(pkgName: string): string { + return pkgName.toUpperCase().replaceAll("-", "_"); +} + +/** + * A worker's preview URL, in Cloudflare's fixed `-..workers.dev` + * shape. Only the router has one — see {@link routerPreviewUrl}. + */ +export function previewUrlFor( + pkgName: string, + previewName: string, + workersDevHost: string, +): string { + return `https://${previewName}-${pkgName}.${workersDevHost}`; +} + +/** + * The preview's public origin, and the only hostname an instance exposes. The router serves the + * frontend and proxies `/api/*` and `/gatekeeper//*` over service bindings, so no other + * worker needs to be reachable by name — and preview URLs are public, so giving the backend and + * the sixteen gatekeepers one would publish a way around the router for no gain. Every other + * worker's URL config is derived from this one. + * + * A preview URL cannot be moved to a zone: Cloudflare only serves them from workers.dev, and the + * `previews` config block has no route or custom-domain key. A *baseline* can have a custom + * domain, but that is a different worker from the previews branched off it. + */ +export function routerPreviewUrl(previewName: string, workersDevHost: string): string { + return previewUrlFor("router", previewName, workersDevHost); +} + +/** Slugify an arbitrary ref name into a legal preview name, truncating with a stable hash. */ +export function slugifyPreviewName( + raw: string, + { reserve = 0 }: { reserve?: number } = {}, +): string { + const budget = MAX_PREVIEW_NAME_LENGTH - reserve; + const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (!slug) return "preview"; + if (slug.length <= budget) return slug; + + const hash = createHash("sha256").update(raw).digest("hex").slice(0, PREVIEW_NAME_HASH_LENGTH); + const prefix = slug.slice(0, budget - PREVIEW_NAME_HASH_LENGTH - 1).replace(/-+$/g, ""); + return `${prefix}-${hash}`; +} + +// Local fallback when PREVIEW_NAME is unset: the current branch, else the current revision. +function localRefName(): string { + for (const argv of [["branch", "--show-current"], ["rev-parse", "--short", "HEAD"]]) { + try { + const out = execFileSync("git", argv, + { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + if (out) return argv[0] === "branch" ? out : `local-${out}`; + } catch { + // Not a git checkout, or a detached HEAD with no branch: try the next form. + } + } + return "local-preview"; +} + +/** + * The preview name, which is also the first label of its hostname — so CI passes the pull request's + * number *and* its head branch, and a preview reads as `pr123-my-branch-router.`: still + * recognizable, but unique per pull request. The number is what makes it unique, since two live + * branches can slugify to one name (`feature/foo`, `feature-foo` and `Feature_Foo` all do) and would + * otherwise share a single instance, overwriting each other and deleting each other on close. + * + * With no number — a local run — the name is the bare slug, and {@link previewPullRequestNumber} + * reads none back out of it. The residual is that renaming a branch mid-review orphans the preview + * deployed under the old name, until the nightly sweep collects it. + */ +export function resolvePreviewName( + { name = process.env.PREVIEW_NAME, prNumber = process.env.PREVIEW_PR_NUMBER }: + { name?: string; prNumber?: string } = {}, +): string { + const ref = name || localRefName(); + // Anything that is not a number is treated as absent: on a scheduled run the workflow's + // interpolation is the empty string, and no other value names a pull request. + const pr = (prNumber ?? "").trim(); + if (!/^\d+$/.test(pr)) return slugifyPreviewName(ref); + const prefix = `pr${pr}-`; + return `${prefix}${slugifyPreviewName(ref, { reserve: prefix.length })}`; +} + +export function previewPullRequestNumber(previewName: string): number | undefined { + const match = /^pr(\d+)-/.exec(previewName); + return match ? Number(match[1]) : undefined; +} + +/** + * The deployment admins seeded into the preview's ADMINS secret, from comma-separated + * PREVIEW_ADMINS. These are Cloudflare Access identities — an email each, since that is what + * {@link resolveAccess} makes the account name — so an entry that is not an email matches nobody. + */ +export function resolveAdmins( + { list = process.env.PREVIEW_ADMINS }: { list?: string } = {}, +): string[] { + const admins = (list ?? "").split(",").map((s) => s.trim()).filter(Boolean); + if (admins.length === 0) { + console.warn("PREVIEW_ADMINS is unset: the preview will have no deployment admins, so the " + + "/admin panel will be unreachable."); + } + return admins; +} + +/** + * Strip ids, names and other account-specific config from a resource binding list, leaving only + * the binding name — which is how Wrangler is told to provision a fresh resource. + */ +function previewResourceBindings(resources: unknown): BindingDecl[] | undefined { + if (!Array.isArray(resources)) return undefined; + const bindings = (resources as { binding?: unknown }[]) + .map((resource) => ({ binding: resource.binding })) + .filter((resource): resource is BindingDecl => typeof resource.binding === "string"); + return bindings.length > 0 ? bindings : undefined; +} + +// The resource lists a baseline is given fresh rather than inheriting from the deployment its +// config was written for. All four hold `{ binding }`-shaped entries, which is what lets one loop +// do all of them. +const BASELINE_RESOURCE_KEYS = [ + "kv_namespaces", "r2_buckets", "d1_databases", "worker_loaders", +] as const satisfies readonly (keyof StagingConfig)[]; + +/** + * Do the same to the *baseline*, in place. + * + * The committed wrangler.jsonc files name resources for the deployment they were written for — + * `bucket_name: "gadgets-blueprint-content"`, and KV entries carrying only a `preview_id`, which + * is a local-dev Miniflare name rather than a namespace id. Passing those through would bind the + * baseline to a bucket named for a different deployment, and would leave the KV bindings with no + * id at all. + * + * A baseline exists here only so previews have something to branch from; nothing should ever + * read its data. So it gets provisioned resources of its own, exactly as a preview does. + */ +function stripBaselineResources(config: StagingConfig): void { + for (const key of BASELINE_RESOURCE_KEYS) { + const stripped = previewResourceBindings(config[key]); + if (stripped) config[key] = stripped; + } +} + +// Previews are throwaway, so invocation logs are worth their cost here even though production +// leaves them off. +function previewObservability(config: StagingConfig): ObservabilityConfig { + return { + ...config.observability, + enabled: true, + logs: { ...config.observability?.logs, invocation_logs: true }, + }; +} + +// How the backend calls gatekeepers: the GatekeeperVendor RPC entrypoint. +function backendGatekeeperServices(gatekeepers: string[], baseUrl: string): PreviewService[] { + return gatekeepers.map((pkgName) => ({ + binding: gatekeeperBindingName(pkgName), + service: pkgName, + entrypoint: "GatekeeperVendor", + // The Context gatekeeper namespaces each workshop's shared data by a "sharingDomain" carried + // in its binding props (packages/gatekeeper-context/src/domain.ts). Using the preview's own + // origin — as manifest-lib.mjs does with $PUBLIC_BASE_URL — keeps each preview's collections + // isolated from every other preview sharing the baseline gatekeeper. + ...(pkgName === "gatekeeper-context" ? { props: { sharingDomain: baseUrl } } : {}), + })); +} + +// How the router calls gatekeepers: the default entrypoint, since it forwards whole HTTP +// requests rather than making vendor RPC calls. +function routerGatekeeperServices(gatekeepers: string[]): PreviewService[] { + return gatekeepers.map((pkgName) => ({ + binding: gatekeeperBindingName(pkgName), + service: pkgName, + })); +} + +function applyGatekeeper( + pkgName: string, + config: StagingConfig, + { baseUrl }: PreviewContext, +): void { + config.vars = { + ...config.vars, + // Every gatekeeper is mounted under the router's origin, exactly as manifest-lib.mjs + // templates it for real instances ($PUBLIC_BASE_URL/gatekeeper/). + BASE_URL: `${baseUrl}/gatekeeper/${gatekeeperShortName(pkgName)}`, + }; + config.previews = { + observability: previewObservability(config), + vars: { ...config.vars }, + ...(config.unsafe ? { unsafe: config.unsafe } : {}), + ...(config.artifacts ? { artifacts: config.artifacts } : {}), + ...(config.ai ? { ai: config.ai } : {}), + ...(config.browser ? { browser: config.browser } : {}), + }; + + // KV namespaces and R2 buckets are auto-provisioned per preview: each preview gets its own, + // and `wrangler preview delete` collects them. + const kvNamespaces = previewResourceBindings(config.kv_namespaces); + if (kvNamespaces) config.previews.kv_namespaces = kvNamespaces; + const r2Buckets = previewResourceBindings(config.r2_buckets); + if (r2Buckets) config.previews.r2_buckets = r2Buckets; +} + +function applyBackend( + config: StagingConfig, + { baseUrl, gatekeepers }: PreviewContext, +): void { + // Injected rather than read from wrangler.jsonc, mirroring what manifest-lib.mjs hardcodes for + // every deployed backend (webFetch's toMarkdown conversion depends on it). + config.ai = { binding: "WORKERS_AI" }; + config.services = backendGatekeeperServices(gatekeepers, baseUrl); + // The origin is the only value the backend needs that is safe to write down here: ADMINS and the + // Cloudflare Access pair are uploaded as *secrets* instead, out of band, because Wrangler prints + // every plain-text var's value in its deploy summary and this workflow's logs are public. See + // {@link backendSecrets} and preview.ts. + config.vars = { + ...config.vars, + PUBLIC_BASE_URL: baseUrl, + }; + config.previews = { + observability: previewObservability(config), + vars: { ...config.vars }, + ...(config.unsafe ? { unsafe: config.unsafe } : {}), + // preview.ts patches each entry's preview_id once the gatekeeper previews exist. + services: backendGatekeeperServices(gatekeepers, baseUrl), + kv_namespaces: previewResourceBindings(config.kv_namespaces), + r2_buckets: previewResourceBindings(config.r2_buckets), + worker_loaders: previewResourceBindings(config.worker_loaders), + ai: config.ai, + ...(config.browser ? { browser: config.browser } : {}), + }; +} + +function applyRouter(config: StagingConfig, { gatekeepers }: PreviewContext): void { + // The one worker in the instance with a hostname: it is the origin, and everything else is + // reached through it. See routerPreviewUrl. + config.workers_dev = true; + config.preview_urls = true; + config.services = [ + { binding: "WORKSHOP_BACKEND", service: "workshop-backend" }, + ...routerGatekeeperServices(gatekeepers), + ]; + config.previews = { + observability: previewObservability(config), + // Patched with preview_ids for the backend and every gatekeeper by preview.ts. Static + // assets are not a `previews` key — the preview inherits the top-level `assets` stanza. + services: structuredClone(config.services), + }; +} + +/** + * Build every package's preview config. Pure: `packages` is `[{ name, config }]` with `config` + * the parsed wrangler.jsonc, and the result is a `Map` from package name to its preview config. + */ +export function buildPreviewConfigs({ + previewName, + packages, + accountId, + workersDevHost, +}: { + previewName: string; + packages: readonly PackageConfig[]; + accountId: string; + workersDevHost: string; +}): Map { + if (!accountId || !workersDevHost) { + throw new Error("buildPreviewConfigs needs both accountId and workersDevHost"); + } + const baseUrl = routerPreviewUrl(previewName, workersDevHost); + const gatekeepers = packages.map((pkg) => pkg.name).filter(isGatekeeper).toSorted(); + const context: PreviewContext = { baseUrl, gatekeepers }; + const configs = new Map(); + + for (const pkg of packages) { + const config: StagingConfig = structuredClone(pkg.config); + if (config.name !== pkg.name) { + // Checked before the rename below, not after: every URL and service binding here is + // derived from the package directory name, so a worker whose own name diverges from it + // would be silently misconfigured rather than merely renamed. + throw new Error(`${pkg.name}/wrangler.jsonc declares worker name "${config.name}"; the ` + + `preview generator requires them to match`); + } + + // `preview_urls` defaults to `workers_dev` when unset, but both are stated so a preview's + // reachability never depends on a default changing, or on what the dashboard was last + // toggled to — a deploy overwrites the dashboard value either way. applyRouter turns them + // back on for the one worker that needs them. + Object.assign(config, { + name: pkg.name, + account_id: accountId, + workers_dev: false, + preview_urls: false, + }); + // The account has no zone here, and a preview cannot be served from one regardless. + delete config.routes; + stripBaselineResources(config); + + if (isGatekeeper(pkg.name)) applyGatekeeper(pkg.name, config, context); + else if (pkg.name === "workshop-backend") applyBackend(config, context); + else if (pkg.name === "router") applyRouter(config, context); + else throw new Error(`cannot build a preview config for package: ${pkg.name}`); + + configs.set(pkg.name, config); + } + + assertBucketNamesFit(configs); + return configs; +} + +/** + * Check {@link MAX_PREVIEW_NAME_LENGTH} against the workers and bindings that actually exist, so + * a new worker with a longer name — or a longer binding on an existing one — fails here, naming + * the number to lower, rather than at bucket creation halfway through a deploy. + */ +function assertBucketNamesFit(configs: Map): void { + for (const [pkgName, config] of configs) { + for (const { binding } of config.previews?.r2_buckets ?? []) { + const suffix = binding.toLowerCase().replaceAll("_", "-"); + const length = `${config.name}-`.length + MAX_PREVIEW_NAME_LENGTH + `-${suffix}`.length; + if (length <= R2_MAX_BUCKET_NAME_LENGTH) continue; + throw new Error(`${pkgName}'s ${binding} bucket would be ${length} characters for a ` + + `${MAX_PREVIEW_NAME_LENGTH}-character preview name, over R2's ` + + `${R2_MAX_BUCKET_NAME_LENGTH}; lower MAX_PREVIEW_NAME_LENGTH to ` + + `${MAX_PREVIEW_NAME_LENGTH - (length - R2_MAX_BUCKET_NAME_LENGTH)}`); + } + } +} + +/** + * The Cloudflare account previews deploy to, and its workers.dev subdomain. + * + * Neither has a default: this repository is public, so which account it deploys to is deployment + * configuration rather than something to hardcode here, and a wrong-but-plausible default would + * deploy somewhere unintended instead of failing. They are resolved together because every + * gatekeeper's BASE_URL is derived from the host — setting one without the other would bake one + * account's hostnames into another account's workers. + * + * The environment is read in the parameter defaults rather than the body so that + * env-passthrough.test.ts, whose discovery is textual, can see both names. + */ +export function resolveTarget({ + accountId = process.env.CLOUDFLARE_ACCOUNT_ID, + workersDevHost = process.env.PREVIEW_WORKERS_DEV_HOST, +}: { accountId?: string; workersDevHost?: string } = {}): { + accountId: string; + workersDevHost: string; +} { + if (!accountId || !workersDevHost) { + const missing = [ + ...(accountId ? [] : ["CLOUDFLARE_ACCOUNT_ID"]), + ...(workersDevHost ? [] : ["PREVIEW_WORKERS_DEV_HOST"]), + ]; + throw new Error(`${missing.join(" and ")} must be set: together they name the Cloudflare ` + + "account previews deploy to, and this repository deliberately hardcodes no default"); + } + return { accountId, workersDevHost }; +} + +/** The Cloudflare Access application that authenticates a preview. */ +export interface AccessConfig { + /** The application's audience tag, matched as the JWT's `aud`. */ + aud: string; + /** Team domain that issued the JWT, e.g. `https://.cloudflareaccess.com`. */ + iss: string; +} + +/** + * The Cloudflare Access application a preview sits behind. + * + * Required, like {@link resolveTarget}'s pair and for the same reasons. A preview is a public + * workers.dev URL holding real gatekeeper capabilities, and an unset pair does not fail — the + * backend can verify no assertion (access.ts) and quietly falls back to password signup — so this + * fails closed rather than deploying an instance whose auth silently differs. The two move together + * because verifying an assertion needs both, and either one alone verifies nothing. + * + * The environment is read in the parameter defaults rather than the body so that + * env-passthrough.test.js, whose discovery is textual, can see both names. + */ +export function resolveAccess({ + aud = process.env.CF_ACCESS_AUD, + iss = process.env.CF_ACCESS_ISS, +}: { aud?: string; iss?: string } = {}): AccessConfig { + if (!aud || !iss) { + const missing = [ + ...(aud ? [] : ["CF_ACCESS_AUD"]), + ...(iss ? [] : ["CF_ACCESS_ISS"]), + ]; + throw new Error(`${missing.join(" and ")} must be set: together they name the Cloudflare ` + + "Access application that authenticates a preview, and a preview deployed without it " + + "would fall back to password signup on a public URL"); + } + return { aud, iss }; +} + +/** + * The AI Gateway configuration, or nothing. + * + * Optional as a group, unlike {@link resolveAccess}: with CF_AI_GATEWAY unset a preview is BYOK, + * exactly like a deployment that never configured a gateway, and the agent still works. Set, it + * routes inference through Cloudflare AI Gateway with server-managed keys — and then the gateway + * account and its Run + Read token are required, because `AiGatewayConfig` (ai-gateway.ts) throws + * without them. That throw would otherwise land in a chat rather than in this deploy. + * + * CF_AI_GATEWAY_WAI is deliberately not offered: it cannot be combined with CF_AI_GATEWAY_WAI_DIRECT + * (the backend rejects the pair), and one knob for where Workers AI inference goes is enough. + * + * The environment is read in the parameter defaults rather than the body so that + * env-passthrough.test.js, whose discovery is textual, can see every name. + */ +export function resolveAiGateway({ + gateway = process.env.CF_AI_GATEWAY, + accountId = process.env.CF_AI_GATEWAY_ACCOUNT_ID, + apiToken = process.env.CF_AI_GATEWAY_API_TOKEN, + providers = process.env.CF_AI_GATEWAY_PROVIDERS, + waiDirect = process.env.CF_AI_GATEWAY_WAI_DIRECT, +}: { + gateway?: string; + accountId?: string; + apiToken?: string; + providers?: string; + waiDirect?: string; +} = {}): Record { + const rest = { CF_AI_GATEWAY_ACCOUNT_ID: accountId, CF_AI_GATEWAY_API_TOKEN: apiToken, + CF_AI_GATEWAY_PROVIDERS: providers, CF_AI_GATEWAY_WAI_DIRECT: waiDirect }; + if (!gateway) { + // Every one of these does nothing without a gateway name, so a set of them without it is a + // half-finished configuration rather than a deliberate BYOK preview. + const orphans = Object.entries(rest).filter(([, value]) => value).map(([name]) => name); + if (orphans.length > 0) { + console.warn(`CF_AI_GATEWAY is unset, so ${orphans.join(", ")} will be ignored: the preview ` + + "will ask each user for their own model API keys."); + } + return {}; + } + if (!accountId || !apiToken) { + const missing = [ + ...(accountId ? [] : ["CF_AI_GATEWAY_ACCOUNT_ID"]), + ...(apiToken ? [] : ["CF_AI_GATEWAY_API_TOKEN"]), + ]; + throw new Error(`${missing.join(" and ")} must be set when CF_AI_GATEWAY is: inference goes ` + + "over HTTPS with a Run + Read token, so the backend refuses to start a chat without it"); + } + return { + CF_AI_GATEWAY: gateway, + CF_AI_GATEWAY_ACCOUNT_ID: accountId, + CF_AI_GATEWAY_API_TOKEN: apiToken, + // Both are optional on their own: no providers means the gateway offers no server-keyed model, + // and no WAI_DIRECT routes Workers AI through the gateway itself. + ...(providers ? { CF_AI_GATEWAY_PROVIDERS: providers } : {}), + ...(waiDirect ? { CF_AI_GATEWAY_WAI_DIRECT: waiDirect } : {}), + }; +} + +/** + * The backend's secrets, keyed by the variable name it reads them as. + * + * These are deliberately *not* part of any generated config. Wrangler prints the value of every + * plain-text var in its deploy summary (truncated to about forty characters) and every + * `unsafe.bindings` entry verbatim in its configuration warning, and the preview workflow's logs + * are public — so a maintainer's email, the audience tag and the team domain would all end up + * there. GitHub's secret masking does not save them either: it replaces exact occurrences of a + * registered secret, and what reaches the log is a reformatted, truncated slice of one. preview.ts + * uploads these over stdin instead, with `wrangler preview secret bulk`, which prints names and + * `********`. + * + * Setting the Access pair is also what closes the password path — the backend's `login()` and + * `createAccount()` both throw once CF_ACCESS_AUD is set — so DISABLE_PASSWORD_AUTH is not needed. + */ +export function backendSecrets({ + admins = resolveAdmins(), + access = resolveAccess(), + aiGateway = resolveAiGateway(), +}: { + admins?: string[]; + access?: AccessConfig; + aiGateway?: Record; +} = {}): Record { + return { + // A secret is always text, so the array goes over as JSON. `#isAdmin()` already accepts that + // form: ".env doesn't actually let you specify JSON bindings, so we also support a string that + // parses as JSON array". + ADMINS: JSON.stringify(admins), + CF_ACCESS_AUD: access.aud, + CF_ACCESS_ISS: access.iss, + // The gateway name and its account are not secret in the way the token is, but they travel the + // same way rather than as vars: one mechanism, and nothing about the deployment in the log. + ...aiGateway, + }; +} + +/** Read every deployable package's wrangler.jsonc off disk. */ +export function readPackages(): DeployablePackage[] { + return findDeployablePackages(PACKAGES_DIR) + .map(({ name, dir }) => ({ name, dir, config: readWranglerConfig(dir) })); +} + +/** Write one package's generated preview config to its `wrangler.staging.jsonc`. */ +export function writePreviewConfig(pkgDir: string, config: StagingConfig): void { + writeFileSync(join(pkgDir, STAGING_CONFIG_NAME), JSON.stringify(config, null, 2) + "\n"); +} + +/** + * Generate and write every package's preview config. Returns what the deploy/delete commands + * need: the resolved preview name, the origin, and the packages in no particular order. + */ +export function generatePreviewConfigs(options: { + previewName?: string; +} = {}): { + previewName: string; + workersDevHost: string; + baseUrl: string; + packages: DeployablePackage[]; +} { + const previewName = options.previewName ?? resolvePreviewName(); + const { accountId, workersDevHost } = resolveTarget(); + const packages = readPackages(); + const configs = buildPreviewConfigs({ + previewName, + packages, + accountId, + workersDevHost, + }); + + for (const pkg of packages) { + const config = configs.get(pkg.name); + if (!config) throw new Error(`no preview config was generated for ${pkg.name}`); + writePreviewConfig(pkg.dir, config); + console.log(`generated: ${join(pkg.dir, STAGING_CONFIG_NAME)}`); + } + console.log(`\nDone. ${packages.length} file(s) generated for preview "${previewName}".`); + + return { + previewName, + workersDevHost, + baseUrl: routerPreviewUrl(previewName, workersDevHost), + packages, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + generatePreviewConfigs(); +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 00000000..9b312a8b --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,23 @@ +// Type-checks `scripts/` — the repo's build/release/dev tooling, which no package tsconfig +// includes. Nothing here is compiled: every module runs directly under `node`, which strips the +// types. `erasableSyntaxOnly` is what keeps that a guarantee. +// +// Checked by `pnpm types:scripts` in CI's lint job, deliberately NOT by `pnpm build` (hot-path and no need) +// `scripts/oxlint-plugin.mjs` stays JS because oxlint's plugin runtime loads it, not +// Node's module loader. +{ + "extends": "../tsconfig.json", + "compilerOptions": { + // Node resolves these specifiers for real, unlike the packages' bundled "bundler" mode, so + // imports name the file on disk (`./staging-config.ts`) and need the extension allowed. + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "types": ["node"], + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + // The root sets allowJs; oxlint-plugin.mjs stays untyped. + "checkJs": false + }, + "include": ["**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts index cde8d929..b7beab74 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -137,7 +137,18 @@ export default defineConfig({ }, }, { - files: ['scripts/**/*.mjs', '*.js', '*.mjs'], + files: ['scripts/**/*.ts', 'scripts/**/*.mjs', '*.js', '*.mjs'], + env: { + node: true, + es2024: true, + }, + }, + { + // `scripts/` tests run under `node --test`, not vitest, so they must not pick up the + // vitest override above (which would supply vitest globals and drop `env: node`). Ordered + // last so it wins over that entry. + files: ['scripts/**/*.test.ts'], + plugins: ['typescript', 'unicorn', 'oxc', 'import'], env: { node: true, es2024: true,