# Files changed · PR \#23872 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23872/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[ci\(relay\): deploy labeled PRs to relay preview stages](https://git.cafe/pingdotgg/t3code/pull/23872.md)

Version: 24

Head commit: 91874283c2d98a56587d20cbd2a2f6c40349877d

Base commit: 2c8bdf6177159d8fcd12a897d7c9311f6eb93237

Comparison base: 52c2b38ea41ee2271f5aca527009d36d25432689

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## \.github/workflows/relay-preview-cleanup.yml

Status: added; +203; −0

```diff
@@ -1,0 +1,203 @@
+name: Clean up relay preview
+
+# Tears down stage `pr-<n>` when `relay:preview` is removed or the PR closes,
+# and retries teardown for closed labeled PRs on a schedule. A label stays on a
+# PR only while its teardown is outstanding, so the scheduled retry lists
+# exactly the failures.
+#
+# This runs under pull_request_target, so the workflow and the Alchemy program
+# come from the default branch. No PR code runs here, which is why it needs no
+# reviewer approval and why a PR cannot edit its own teardown to reach secrets.
+on:
+  pull_request_target:
+    types: [unlabeled, closed]
+  schedule:
+    - cron: "17 7 * * *"
+  workflow_dispatch:
+
+permissions:
+  contents: read
+  issues: read
+  pull-requests: read
+
+jobs:
+  cleanup_pr:
+    name: Tear down relay preview
+    if: >-
+      github.ref == 'refs/heads/main' &&
+      github.event_name == 'pull_request_target' &&
+      (
+        (github.event.action == 'unlabeled' && github.event.label.name == 'relay:preview') ||
+        (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'relay:preview'))
+      )
+    # Job-level so that events which skip this job, such as a closed PR without
+    # the label, never take the group's pending slot. GitHub replaces a pending
+    # run in a group with a newer one, which would drop a queued teardown.
+    concurrency:
+      group: relay-preview-${{ github.event.pull_request.number }}
+      cancel-in-progress: false
+    permissions:
+      contents: read
+      issues: write
+      pull-requests: write
+    runs-on: blacksmith-8vcpu-ubuntu-2404
+    timeout-minutes: 30
+    environment:
+      name: relay-preview-cleanup
+    env:
+      GH_TOKEN: ${{ github.token }}
+      STAGE: pr-${{ github.event.pull_request.number }}
+      CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
+      PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }}
+      AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }}
+      RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}
+      RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }}
+      CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
+      CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }}
+      APNS_ENABLED: "false"
+      ALCHEMY_TELEMETRY_DISABLED: "1"
+    steps:
+      - name: Checkout default branch
+        uses: actions/checkout@v6
+        with:
+          ref: ${{ github.event.repository.default_branch }}
+          sparse-checkout: |
+            /*
+            !/.repos/
+          sparse-checkout-cone-mode: false
+
+      - name: Setup Vite+
+        uses: voidzero-dev/setup-vp@v1
+        with:
+          node-version-file: package.json
+          cache: true
+          run-install: |
+            args:
+              - --filter=t3code-relay...
+
+      # Destroy also runs the preview tunnel sweep, which is part of the stack.
+      - name: Destroy preview stage
+        run: vp run --filter t3code-relay destroy --stage "$STAGE" --yes --no-input
+        env:
+          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+          PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }}
+          PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }}
+          AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }}
+          CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
+
+      # Only reached after a successful destroy. A failed one keeps the label,
+      # so the scheduled retry picks the PR up again.
+      - name: Clear the teardown marker
+        if: github.event.action == 'closed'
+        run: gh pr edit "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --remove-label relay:preview
+
+      # An unlabeled event has already removed the label, so a failed teardown
+      # must put it back or no retry will ever see the stage. Labels added with
+      # GITHUB_TOKEN do not start new runs, so this does not redeploy.
+      - name: Restore the teardown marker after a failed destroy
+        if: failure() && github.event.action == 'unlabeled'
+        run: gh pr edit "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --add-label relay:preview
+
+  list_closed_previews:
+    name: List closed relay previews
+    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request_target'
+    runs-on: blacksmith-8vcpu-ubuntu-2404
+    timeout-minutes: 10
+    outputs:
+      numbers: ${{ steps.list.outputs.numbers }}
+    steps:
+      # The issues endpoint is paginated by gh, so there is no listing cap. Pull
+      # requests appear there with a `pull_request` key.
+      - name: List closed PRs labeled relay:preview
+        id: list
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          numbers=$(gh api --paginate --method GET "repos/$GITHUB_REPOSITORY/issues" \
+            -f labels=relay:preview -f state=closed -f per_page=100 \
+            --jq '.[] | select(.pull_request) | .number') \
+            || { echo "Could not list closed relay:preview PRs" >&2; exit 1; }
+          json=$(printf '%s\n' "$numbers" | jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)')
+          echo "Closed relay:preview PRs: $json"
+          echo "numbers=$json" >> "$GITHUB_OUTPUT"
+
+  sweep_pr:
+    name: Retry teardown for PR ${{ matrix.number }}
+    needs: list_closed_previews
+    if: github.ref == 'refs/heads/main' && needs.list_closed_previews.outputs.numbers != '[]'
+    runs-on: blacksmith-8vcpu-ubuntu-2404
+    timeout-minutes: 30
+    strategy:
+      fail-fast: false
+      max-parallel: 4
+      matrix:
+        number: ${{ fromJSON(needs.list_closed_previews.outputs.numbers) }}
+    # Same group as the deploy and teardown for this PR, so a reopened PR's
+    # deploy cannot run concurrently with a teardown of its previous stage.
+    concurrency:
+      group: relay-preview-${{ matrix.number }}
+      cancel-in-progress: false
+    permissions:
+      contents: read
+      issues: write
+      pull-requests: write
+    environment:
+      name: relay-preview-cleanup
+    env:
+      GH_TOKEN: ${{ github.token }}
+      STAGE: pr-${{ matrix.number }}
+      CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
+      PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }}
+      AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }}
+      RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}
+      RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }}
+      CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
+      CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }}
+      APNS_ENABLED: "false"
+      ALCHEMY_TELEMETRY_DISABLED: "1"
+    steps:
+      # The lock above is taken before this runs, so the state read here is
+      # current. An open PR, including one reopened since the listing, is left
+      # alone. GitHub reports merged PRs as MERGED, so anything not OPEN counts.
+      - name: Confirm the PR is not open
+        id: eligible
+        run: |
+          state=$(gh pr view "${{ matrix.number }}" --repo "$GITHUB_REPOSITORY" \
+            --json state --jq 'if .state == "OPEN" then "no" else "yes" end') \
+            || { echo "Could not read PR ${{ matrix.number }}" >&2; exit 1; }
+          echo "eligible=$state" >> "$GITHUB_OUTPUT"
+          [ "$state" = yes ] || echo "PR ${{ matrix.number }} is open again; leaving $STAGE alone."
+
+      - name: Checkout default branch
+        if: steps.eligible.outputs.eligible == 'yes'
+        uses: actions/checkout@v6
+        with:
+          ref: ${{ github.event.repository.default_branch }}
+          sparse-checkout: |
+            /*
+            !/.repos/
+          sparse-checkout-cone-mode: false
+
+      - name: Setup Vite+
+        if: steps.eligible.outputs.eligible == 'yes'
+        uses: voidzero-dev/setup-vp@v1
+        with:
+          node-version-file: package.json
+          cache: true
+          run-install: |
+            args:
+              - --filter=t3code-relay...
+
+      - name: Destroy preview stage
+        if: steps.eligible.outputs.eligible == 'yes'
+        run: vp run --filter t3code-relay destroy --stage "$STAGE" --yes --no-input
+        env:
+          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+          PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }}
+          PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }}
+          AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }}
+          CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
+
+      - name: Clear the teardown marker
+        if: steps.eligible.outputs.eligible == 'yes'
+        run: gh pr edit "${{ matrix.number }}" --repo "$GITHUB_REPOSITORY" --remove-label relay:preview
```

## \.github/workflows/relay-preview.yml

Status: added; +81; −0

```diff
@@ -1,0 +1,81 @@
+name: Deploy relay preview
+
+# Labeling a same-repository PR `relay:preview` deploys Alchemy stage `pr-<n>`.
+# This job runs the PR's own code with account credentials, so it waits for an
+# approval in the `relay-preview` environment on every run. Teardown is in
+# relay-preview-cleanup.yml and never runs PR code.
+on:
+  pull_request:
+    types: [labeled, synchronize, reopened]
+
+permissions:
+  contents: read
+  pull-requests: read
+
+concurrency:
+  group: relay-preview-${{ github.event.pull_request.number }}
+  cancel-in-progress: false
+
+jobs:
+  deploy_preview:
+    name: Deploy relay preview
+    if: >-
+      github.event.pull_request.head.repo.full_name == github.repository &&
+      contains(github.event.pull_request.labels.*.name, 'relay:preview')
+    runs-on: blacksmith-8vcpu-ubuntu-2404
+    timeout-minutes: 20
+    environment:
+      name: relay-preview
+    env:
+      STAGE: pr-${{ github.event.pull_request.number }}
+      CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
+      PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }}
+      AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }}
+      RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}
+      RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }}
+      CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
+      CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }}
+      # Previews take no alert notifier, no tunnel cleanup, and no custom
+      # domain, so they stay silent and reap nothing outside their own stage.
+      APNS_ENABLED: "false"
+      ALCHEMY_TELEMETRY_DISABLED: "1"
+    steps:
+      - name: Confirm the PR is still open and labeled
+        env:
+          GH_TOKEN: ${{ github.token }}
+          PR_NUMBER: ${{ github.event.pull_request.number }}
+        run: |
+          state=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,labels \
+            --jq 'if .state == "OPEN" and any(.labels[]; .name == "relay:preview") then "ok" else "stale" end')
+          if [ "$state" != "ok" ]; then
+            echo "PR #$PR_NUMBER is no longer open with relay:preview; not deploying." >&2
+            exit 1
+          fi
+
+      - name: Checkout
+        uses: actions/checkout@v6
+        with:
+          ref: ${{ github.event.pull_request.head.sha }}
+          sparse-checkout: |
+            /*
+            !/.repos/
+          sparse-checkout-cone-mode: false
+
+      - name: Setup Vite+
+        uses: voidzero-dev/setup-vp@v1
+        with:
+          node-version-file: package.json
+          cache: true
+          run-install: |
+            args:
+              - --filter=t3code-relay...
+
+      - name: Deploy preview stage
+        run: vp run --filter t3code-relay deploy --stage "$STAGE" --yes --no-input
+        env:
+          T3CODE_RELAY_CLIENT_CONFIG_ENV: ${{ runner.temp }}/relay-client-config.env
+          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+          PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }}
+          PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }}
+          AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }}
+          CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
```

## docs/operations/release.md

Status: modified; +7; −1

````diff
@@ -160,12 +160,18 @@
 Production adopts the configured relay API and tunnel DNS zones as retained Cloudflare resources.
 Personal stages reference the production-owned zones.
 
-Developers deploy personal stages locally rather than through pull-request automation:
+Developers deploy personal stages locally:
 
 ```sh
 vp run --filter t3code-relay deploy -- --stage "$USER" --env-file .env.local
 ```
 
+A same-repository PR labeled `relay:preview` deploys stage `pr-<number>` through
+`.github/workflows/relay-preview.yml`, after approval in the `relay-preview` environment. Removing the
+label or closing the PR runs `.github/workflows/relay-preview-cleanup.yml`, which destroys the stage
+and sweeps its managed tunnels and DNS records. A nightly run destroys stages of closed labeled PRs
+whose teardown failed. Fork PRs get no preview.
+
 ### Managed tunnel cleanup rollout
 
 Keep `RELAY_TUNNEL_CLEANUP_MODE=off` for the first production deploy. That deploy applies the
````

## infra/relay/README.md

Status: modified; +3; −1

```diff
@@ -120,7 +120,9 @@
 The relay is versioned separately from client releases. `.github/workflows/deploy-relay.yml` deploys
 the shared Alchemy `prod` stage on every push to `main`. Stable and nightly release builds both
 resolve their static public config from the same
-`production` GitHub environment. Pull requests do not deploy relay stages. Developers can
+`production` GitHub environment. Labeling a same-repository PR `relay:preview` deploys a `pr-<number>`
+stage, and the cleanup workflow tears it down; see
+[`relay-preview.yml`](../../.github/workflows/relay-preview.yml). Developers can
 deploy personal non-production stages locally with any stage name other than `prod`.
 
 The repository must define these Actions variables shared by relay deployments:
```

## infra/relay/alchemy.run.ts

Status: modified; +13; −1

```diff
@@ -11,7 +11,9 @@
 import { PublishClientConfig, tokenDigest } from "./src/clientConfig.ts";
 import * as RelayDb from "./src/db.ts";
 import { RelayObservability } from "./src/observability.ts";
-import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts";
+import { isPreviewStage } from "./src/previewStages.ts";
+import { PreviewTunnelSweep, PreviewTunnelSweepProvider } from "./src/previewTunnelSweep.ts";
+import { ManagedEndpointZone, RelayApiZone, RelayDeploymentConfig } from "./src/zone.ts";
 import * as RelayWorker from "./src/worker.ts";
 
 export default Alchemy.Stack(
@@ -22,16 +24,26 @@
       Cloudflare.providers(),
       Drizzle.providers(),
       Planetscale.providers(),
+      PreviewTunnelSweepProvider(),
     ),
     state: Cloudflare.state(),
   },
   Effect.gen(function* () {
+    const { stage } = yield* Alchemy.Stack;
     const db = yield* RelayDb.PlanetscaleDatabase;
     const hyperdrive = yield* RelayDb.RelayHyperdrive;
     const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie);
     const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie);
     const observability = yield* RelayObservability;
     const api = yield* RelayWorker.Api;
+    if (isPreviewStage(stage)) {
+      const { managedEndpointZoneName } = yield* RelayDeploymentConfig;
+      yield* PreviewTunnelSweep("PreviewTunnelSweep", {
+        stage,
+        tunnelZoneName: managedEndpointZoneName,
+        workerName: api.workerName,
+      });
+    }
     yield* PublishClientConfig({
       url: api.url,
       mobileTracingUrl: observability.traces.otelTracesEndpoint,
```

## infra/relay/src/previewStages.test.ts

Status: added; +60; −0

```diff
@@ -1,0 +1,60 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+  isPreviewStage,
+  isPreviewTunnelName,
+  isPreviewTunnelRecordName,
+  previewSweepTargets,
+  tunnelCnameContent,
+} from "./previewStages.ts";
+
+const KEY = "0123456789abcdef";
+
+describe("preview stages", () => {
+  it("accepts only pull-request stages", () => {
+    expect(isPreviewStage("pr-42")).toBe(true);
+    expect(isPreviewStage("prod")).toBe(false);
+    expect(isPreviewStage("dev_julius")).toBe(false);
+    expect(isPreviewStage("pr-0")).toBe(false);
+    expect(isPreviewStage("pr-42/../prod")).toBe(false);
+  });
+
+  it("matches only tunnels of the exact stage", () => {
+    expect(isPreviewTunnelName(`t3coderelay-managedendpoint-pr-42-${KEY}`, "pr-42")).toBe(true);
+    expect(isPreviewTunnelName(`t3coderelay-managedendpoint-pr-42-${KEY}`, "pr-4")).toBe(false);
+    expect(isPreviewTunnelName(`t3coderelay-managedendpoint-prod-${KEY}`, "pr-42")).toBe(false);
+    expect(isPreviewTunnelName(`t3coderelay-managedendpoint-pr-42-xyz`, "pr-42")).toBe(false);
+  });
+
+  it("matches only hostnames of the exact stage under the tunnel zone", () => {
+    const zone = "tunnels.example.com";
+    expect(isPreviewTunnelRecordName(`pr-42-${KEY}.${zone}`, "pr-42", zone)).toBe(true);
+    expect(isPreviewTunnelRecordName(`PR-42-${KEY}.Tunnels.example.com`, "pr-42", zone)).toBe(true);
+    expect(isPreviewTunnelRecordName(`pr-4-${KEY}.${zone}`, "pr-42", zone)).toBe(false);
+    expect(isPreviewTunnelRecordName(`pr-42-${KEY}.other.com`, "pr-42", zone)).toBe(false);
+    expect(isPreviewTunnelRecordName(`relay.${zone}`, "pr-42", zone)).toBe(false);
+  });
+
+  it("targets only this stage's tunnels and the records that point at them", () => {
+    const zone = "tunnels.example.com";
+    const mine = `t3coderelay-managedendpoint-pr-42-${KEY}`;
+    const otherStage = `t3coderelay-managedendpoint-pr-7-${KEY}`;
+    const otherKey = "fedcba9876543210";
+    const targets = previewSweepTargets({
+      stage: "pr-42",
+      tunnelZoneName: zone,
+      tunnels: [
+        { id: "t-mine", name: mine },
+        { id: "t-other", name: otherStage },
+      ],
+      records: [
+        { id: "r-mine", name: `pr-42-${KEY}.${zone}`, content: tunnelCnameContent("t-mine") },
+        { id: "r-other", name: `pr-7-${KEY}.${zone}`, content: tunnelCnameContent("t-other") },
+        // Right label, but it targets a tunnel outside this sweep.
+        { id: "r-stranger", name: `pr-42-${otherKey}.${zone}`, content: "x.cfargotunnel.com" },
+      ],
+    });
+    expect(targets.tunnels.map((tunnel) => tunnel.id)).toEqual(["t-mine"]);
+    expect(targets.records.map((record) => record.id)).toEqual(["r-mine"]);
+  });
+});
```

## infra/relay/src/previewStages.ts

Status: added; +74; −0

```diff
@@ -1,0 +1,74 @@
+import {
+  MANAGED_ENDPOINT_KEY_PATTERN,
+  managedEndpointTunnelNamePrefix,
+} from "./deploymentConfig.ts";
+
+// Only pull-request stages may be torn down by the cleanup job. Anchoring the
+// pattern keeps `prod`, personal `dev_<user>` stages, and `pr-0` out of reach.
+const PREVIEW_STAGE_PATTERN = /^pr-[1-9][0-9]*$/u;
+
+export function isPreviewStage(stage: string): boolean {
+  return PREVIEW_STAGE_PATTERN.test(stage);
+}
+
+// The prefix ends with a dash, so stage `pr-4` never matches tunnels of `pr-42`.
+export function isPreviewTunnelName(name: string, stage: string): boolean {
+  if (!isPreviewStage(stage)) return false;
+  const prefix = managedEndpointTunnelNamePrefix(stage);
+  return name.startsWith(prefix) && MANAGED_ENDPOINT_KEY_PATTERN.test(name.slice(prefix.length));
+}
+
+// Managed endpoint hostnames are `<stage>-<endpoint key>.<tunnel zone>`.
+export function isPreviewTunnelRecordName(
+  recordName: string,
+  stage: string,
+  tunnelZoneName: string,
+): boolean {
+  if (!isPreviewStage(stage)) return false;
+  const name = recordName.toLowerCase();
+  const suffix = `.${tunnelZoneName.trim().toLowerCase()}`;
+  if (!name.endsWith(suffix)) return false;
+  const label = name.slice(0, -suffix.length);
+  const labelPrefix = `${stage}-`;
+  return (
+    label.startsWith(labelPrefix) &&
+    MANAGED_ENDPOINT_KEY_PATTERN.test(label.slice(labelPrefix.length))
+  );
+}
+
+export function tunnelCnameContent(tunnelId: string): string {
+  return `${tunnelId}.cfargotunnel.com`;
+}
+
+export interface SweepTunnel {
+  readonly id: string;
+  readonly name?: string | null | undefined;
+}
+
+export interface SweepRecord {
+  readonly id: string;
+  readonly name: string;
+  readonly content?: string | null | undefined;
+}
+
+// Records are matched through the tunnel they point at, not just by name, so a
+// record that happens to share the label is never deleted unless it targets one
+// of this stage's tunnels.
+export function previewSweepTargets(input: {
+  readonly stage: string;
+  readonly tunnelZoneName: string;
+  readonly tunnels: ReadonlyArray<SweepTunnel>;
+  readonly records: ReadonlyArray<SweepRecord>;
+}): { readonly tunnels: ReadonlyArray<SweepTunnel>; readonly records: ReadonlyArray<SweepRecord> } {
+  const tunnels = input.tunnels.filter(
+    (tunnel) => tunnel.name != null && isPreviewTunnelName(tunnel.name, input.stage),
+  );
+  const tunnelContents = new Set(tunnels.map((tunnel) => tunnelCnameContent(tunnel.id)));
+  const records = input.records.filter(
+    (record) =>
+      isPreviewTunnelRecordName(record.name, input.stage, input.tunnelZoneName) &&
+      record.content != null &&
+      tunnelContents.has(record.content),
+  );
+  return { tunnels, records };
+}
```

## infra/relay/src/previewTunnelSweep.test.ts

Status: added; +102; −0

```diff
@@ -1,0 +1,102 @@
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Layer from "effect/Layer";
+import * as Redacted from "effect/Redacted";
+import * as HttpClient from "effect/http/HttpClient";
+import * as HttpClientResponse from "effect/http/HttpClientResponse";
+
+import { sweepPreviewStage } from "./previewTunnelSweep.ts";
+
+const KEY = "0123456789abcdef";
+const TUNNEL_NAME = `t3coderelay-managedendpoint-pr-42-${KEY}`;
+
+// Answers the Cloudflare endpoints the sweep calls and records each request.
+const fakeCloudflare = (options: {
+  readonly tunnels: ReadonlyArray<{ readonly id: string; readonly name: string }>;
+  readonly zoneExists: boolean;
+  readonly deleteStatus?: number;
+}) => {
+  const seen: Array<string> = [];
+  const client = HttpClient.make((request) => {
+    const url = new URL(request.url);
+    seen.push(`${request.method} ${url.pathname}`);
+    const reply = (status: number, body: unknown) =>
+      Effect.succeed(
+        HttpClientResponse.fromWeb(
+          request,
+          new Response(JSON.stringify(body), {
+            status,
+            headers: { "content-type": "application/json" },
+          }),
+        ),
+      );
+    if (request.method === "DELETE") {
+      return reply(options.deleteStatus ?? 200, { success: true });
+    }
+    if (url.pathname.endsWith("/cfd_tunnel")) return reply(200, { result: options.tunnels });
+    if (url.pathname.endsWith("/zones")) {
+      return reply(200, { result: options.zoneExists ? [{ id: "zone-1" }] : [] });
+    }
+    if (url.pathname.endsWith("/dns_records")) return reply(200, { result: [] });
+    return reply(404, {});
+  });
+  return { client, seen };
+};
+
+const sweep = (cloudflare: ReturnType<typeof fakeCloudflare>) =>
+  sweepPreviewStage({
+    stage: "pr-42",
+    tunnelZoneName: "tunnels.example.com",
+    accountId: "account-1",
+    token: Redacted.make("token"),
+  }).pipe(Effect.provide(Layer.succeed(HttpClient.HttpClient, cloudflare.client)));
+
+describe("sweepPreviewStage", () => {
+  it.effect("does not need the zone once no tunnels remain", () =>
+    Effect.gen(function* () {
+      const cloudflare = fakeCloudflare({ tunnels: [], zoneExists: false });
+      yield* sweep(cloudflare);
+      expect(cloudflare.seen.some((request) => request.includes("/zones"))).toBe(false);
+    }),
+  );
+
+  it.effect("removes tunnels even when their zone is already gone", () =>
+    Effect.gen(function* () {
+      const cloudflare = fakeCloudflare({
+        tunnels: [{ id: "tunnel-1", name: TUNNEL_NAME }],
+        zoneExists: false,
+      });
+      yield* sweep(cloudflare);
+      expect(cloudflare.seen).toContain(
+        "DELETE /client/v4/accounts/account-1/cfd_tunnel/tunnel-1/connections",
+      );
+      expect(cloudflare.seen).toContain("DELETE /client/v4/accounts/account-1/cfd_tunnel/tunnel-1");
+      expect(cloudflare.seen.some((request) => request.includes("/dns_records"))).toBe(false);
+    }),
+  );
+
+  it.effect("treats an already-deleted tunnel as swept", () =>
+    Effect.gen(function* () {
+      const cloudflare = fakeCloudflare({
+        tunnels: [{ id: "tunnel-1", name: TUNNEL_NAME }],
+        zoneExists: false,
+        deleteStatus: 404,
+      });
+      const exit = yield* Effect.exit(sweep(cloudflare));
+      expect(Exit.isSuccess(exit)).toBe(true);
+    }),
+  );
+
+  it.effect("fails when Cloudflare rate-limits a delete so the next run retries it", () =>
+    Effect.gen(function* () {
+      const cloudflare = fakeCloudflare({
+        tunnels: [{ id: "tunnel-1", name: TUNNEL_NAME }],
+        zoneExists: false,
+        deleteStatus: 429,
+      });
+      const exit = yield* Effect.exit(sweep(cloudflare));
+      expect(Exit.isFailure(exit)).toBe(true);
+    }),
+  );
+});
```

## infra/relay/src/previewTunnelSweep.ts

Status: added; +183; −0

```diff
@@ -1,0 +1,183 @@
+import { Resource } from "alchemy";
+import * as Provider from "alchemy/Provider";
+import * as Config from "effect/Config";
+import * as Effect from "effect/Effect";
+import * as Redacted from "effect/Redacted";
+import * as Schema from "effect/Schema";
+import * as FetchHttpClient from "effect/http/FetchHttpClient";
+import * as HttpClient from "effect/http/HttpClient";
+import * as HttpClientRequest from "effect/http/HttpClientRequest";
+import * as HttpClientResponse from "effect/http/HttpClientResponse";
+
+import { managedEndpointTunnelNamePrefix } from "./deploymentConfig.ts";
+import { isPreviewStage, previewSweepTargets } from "./previewStages.ts";
+
+// Managed endpoint tunnels and their CNAMEs are created by the Worker at
+// runtime, so Alchemy never tracks them and `alchemy destroy` leaves them behind.
+// This resource exists only so its delete runs during a preview stage's destroy.
+
+export interface PreviewTunnelSweepProps {
+  readonly stage: string;
+  readonly tunnelZoneName: string;
+  // Depending on the Worker makes Alchemy delete this sweep before the Worker,
+  // because dependents go first. A live Worker could create a tunnel during
+  // that gap; the relay accepts that window.
+  readonly workerName: string;
+}
+
+export interface PreviewTunnelSweepAttributes {
+  readonly stage: string;
+  readonly tunnelZoneName: string;
+  readonly workerName: string;
+}
+
+export type PreviewTunnelSweep = Resource<
+  "T3CodeRelay.PreviewTunnelSweep",
+  PreviewTunnelSweepProps,
+  PreviewTunnelSweepAttributes
+>;
+
+export const PreviewTunnelSweep = Resource<PreviewTunnelSweep>("T3CodeRelay.PreviewTunnelSweep");
+
+const API = "https://api.cloudflare.com/client/v4";
+const PAGE_SIZE = 100;
+
+const TunnelPage = Schema.Struct({
+  result: Schema.Array(
+    Schema.Struct({ id: Schema.String, name: Schema.optional(Schema.NullOr(Schema.String)) }),
+  ),
+});
+const ZonePage = Schema.Struct({ result: Schema.Array(Schema.Struct({ id: Schema.String })) });
+const DnsRecordPage = Schema.Struct({
+  result: Schema.Array(
+    Schema.Struct({
+      id: Schema.String,
+      name: Schema.String,
+      content: Schema.optional(Schema.NullOr(Schema.String)),
+    }),
+  ),
+});
+
+const collectPages = Effect.fn("PreviewTunnelSweep.collectPages")(function* <T, E, R>(
+  fetchPage: (page: number) => Effect.Effect<ReadonlyArray<T>, E, R>,
+) {
+  const items: Array<T> = [];
+  for (let page = 1; ; page += 1) {
+    const batch = yield* fetchPage(page);
+    items.push(...batch);
+    if (batch.length < PAGE_SIZE) return items;
+  }
+});
+
+// Idempotent: a second run lists nothing and deletes nothing.
+export const sweepPreviewStage = Effect.fn("PreviewTunnelSweep.sweep")(function* (input: {
+  readonly stage: string;
+  readonly tunnelZoneName: string;
+  readonly accountId: string;
+  readonly token: Redacted.Redacted<string>;
+}) {
+  const client = yield* HttpClient.HttpClient;
+  const send = (request: HttpClientRequest.HttpClientRequest) =>
+    request.pipe(
+      HttpClientRequest.bearerToken(Redacted.value(input.token)),
+      client.execute,
+      Effect.flatMap(HttpClientResponse.filterStatusOk),
+    );
+  const getJson = <A, I>(url: string, params: Record<string, string>, schema: Schema.Codec<A, I>) =>
+    send(HttpClientRequest.get(url, { urlParams: params })).pipe(
+      Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
+    );
+  // A 404 means the object is already gone, which is the outcome teardown wants.
+  // Any other failure, including 429, fails the step so the next run retries it.
+  const remove = (url: string) =>
+    HttpClientRequest.make("DELETE")(url).pipe(
+      HttpClientRequest.bearerToken(Redacted.value(input.token)),
+      client.execute,
+      Effect.flatMap((response) =>
+        response.status === 404
+          ? Effect.void
+          : HttpClientResponse.filterStatusOk(response).pipe(Effect.asVoid),
+      ),
+    );
+
+  const listedTunnels = yield* collectPages((page) =>
+    getJson(
+      `${API}/accounts/${input.accountId}/cfd_tunnel`,
+      {
+        is_deleted: "false",
+        include_prefix: managedEndpointTunnelNamePrefix(input.stage),
+        per_page: String(PAGE_SIZE),
+        page: String(page),
+      },
+      TunnelPage,
+    ).pipe(Effect.map((body) => body.result)),
+  );
+
+  // No tunnels means nothing to clean, so the zone is not needed and may be gone.
+  if (listedTunnels.length === 0) return;
+
+  const zone = yield* getJson(`${API}/zones`, { name: input.tunnelZoneName }, ZonePage);
+  const zoneId = zone.result[0]?.id;
+  // Without the zone there are no CNAMEs to remove, but the tunnels still must go.
+  const listedRecords =
+    zoneId === undefined
+      ? []
+      : yield* collectPages((page) =>
+          getJson(
+            `${API}/zones/${zoneId}/dns_records`,
+            { type: "CNAME", per_page: String(PAGE_SIZE), page: String(page) },
+            DnsRecordPage,
+          ).pipe(Effect.map((body) => body.result)),
+        );
+
+  const targets = previewSweepTargets({
+    stage: input.stage,
+    tunnelZoneName: input.tunnelZoneName,
+    tunnels: listedTunnels,
+    records: listedRecords,
+  });
+
+  // Records first, so no hostname points at a tunnel that is about to vanish.
+  if (zoneId !== undefined) {
+    for (const record of targets.records) {
+      yield* remove(`${API}/zones/${zoneId}/dns_records/${record.id}`);
+    }
+  }
+  // Cloudflare refuses to delete a tunnel that still has connectors attached.
+  for (const tunnel of targets.tunnels) {
+    yield* remove(`${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}/connections`);
+    yield* remove(`${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}`);
+  }
+});
+
+export const PreviewTunnelSweepProvider = () =>
+  Provider.succeed(
+    PreviewTunnelSweep,
+    PreviewTunnelSweep.Provider.of({
+      reconcile: ({ news }) =>
+        Effect.succeed({
+          stage: news.stage,
+          tunnelZoneName: news.tunnelZoneName,
+          workerName: news.workerName,
+        }),
+      // Nothing in Cloudflare is created for this resource, so there is nothing to list.
+      list: () => Effect.succeed([]),
+      delete: Effect.fn(function* ({ output }) {
+        // The sweep runs only on preview stages. This guard keeps a mistaken
+        // stage from reaching the production relay's tunnels.
+        if (!isPreviewStage(output.stage)) {
+          return yield* Effect.die(`Refusing to sweep non-preview stage '${output.stage}'`);
+        }
+        // Read from the environment, not the Cloudflare service, so the provider
+        // needs no extra service in its layer. CI sets both for the destroy step.
+        const accountId = yield* Config.String("CLOUDFLARE_ACCOUNT_ID");
+        const token = yield* Config.Redacted("CLOUDFLARE_API_TOKEN");
+        yield* sweepPreviewStage({
+          stage: output.stage,
+          tunnelZoneName: output.tunnelZoneName,
+          accountId,
+          token,
+        }).pipe(Effect.provide(FetchHttpClient.layer));
+      }),
+    }),
+  );
```
