From ea5dea29556a9d8a66f02d8da33002f57ff87a1d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 14:48:19 -0700 Subject: [PATCH 1/9] ci(relay): deploy labeled PRs to relay preview stages Relay changes were only testable by deploying prod or a personal stage, so PR changes to the relay went unexercised until merge. Labeling a same-repo PR relay:preview now deploys stage pr- behind the relay-preview environment's reviewer gate. Teardown runs from the default branch on unlabel or close, with no PR code and no reviewer wait, then sweeps managed tunnels and their CNAMEs, which Alchemy does not track. A nightly job retries teardown for closed labeled PRs. Dry-run sweep by default; refuses non-pr- stages. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/relay-preview-cleanup.yml | 141 +++++++++++++++++++ .github/workflows/relay-preview.yml | 81 +++++++++++ docs/operations/release.md | 8 +- infra/relay/README.md | 4 +- infra/relay/package.json | 1 + infra/relay/scripts/sweep-preview-tunnels.ts | 134 ++++++++++++++++++ infra/relay/src/previewStages.test.ts | 31 ++++ infra/relay/src/previewStages.ts | 41 ++++++ 8 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/relay-preview-cleanup.yml create mode 100644 .github/workflows/relay-preview.yml create mode 100644 infra/relay/scripts/sweep-preview-tunnels.ts create mode 100644 infra/relay/src/previewStages.test.ts create mode 100644 infra/relay/src/previewStages.ts diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml new file mode 100644 index 000000000000..14b8ddb4a0d2 --- /dev/null +++ b/.github/workflows/relay-preview-cleanup.yml @@ -0,0 +1,141 @@ +name: Clean up relay preview + +# Tears down stage `pr-` when `relay:preview` is removed or the PR closes, +# and sweeps stages left behind by a failed teardown. +# +# 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 + pull-requests: read + +concurrency: + group: relay-preview-${{ github.event.pull_request.number || 'sweep' }} + cancel-in-progress: false + +jobs: + cleanup_pr: + name: Tear down relay preview + if: >- + 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')) + ) + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + environment: + name: relay-preview-cleanup + 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 }} + 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 first, so the Worker that creates tunnels is gone before the + # sweep runs. If destroy fails the sweep is skipped, and the nightly + # sweep retries both. + - 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 }} + + - name: Sweep preview tunnels and DNS + run: vp run --filter t3code-relay sweep:preview + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PREVIEW_STAGE: ${{ env.STAGE }} + APPLY: "true" + + sweep_orphans: + name: Sweep orphaned relay previews + if: github.event_name != 'pull_request_target' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 60 + environment: + name: relay-preview-cleanup + env: + GH_TOKEN: ${{ github.token }} + 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: + 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: Destroy and sweep stages of closed labeled PRs + env: + APPLY: "true" + 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 }} + run: | + failed=0 + for number in $(gh pr list --repo "$GITHUB_REPOSITORY" --state closed \ + --label "relay:preview" --limit 200 --json number --jq '.[].number'); do + stage="pr-$number" + echo "::group::$stage" + vp run --filter t3code-relay destroy --stage "$stage" --yes --no-input \ + && PREVIEW_STAGE="$stage" vp run --filter t3code-relay sweep:preview \ + || { echo "Cleanup failed for $stage" >&2; failed=1; } + echo "::endgroup::" + done + exit "$failed" diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml new file mode 100644 index 000000000000..53a71110cef1 --- /dev/null +++ b/.github/workflows/relay-preview.yml @@ -0,0 +1,81 @@ +name: Deploy relay preview + +# Labeling a same-repository PR `relay:preview` deploys Alchemy stage `pr-`. +# 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 }} diff --git a/docs/operations/release.md b/docs/operations/release.md index a55643e4ef34..6f1c39539748 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -160,12 +160,18 @@ database. Local personal stages provision isolated branches from it and are neve 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-` 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 diff --git a/infra/relay/README.md b/infra/relay/README.md index edcd73415477..5fe620a835ec 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -120,7 +120,9 @@ one of those outputs changed, and `T3CODE_RELAY_CLIENT_CONFIG_ENV` redirects it 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-` +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: diff --git a/infra/relay/package.json b/infra/relay/package.json index 6ff488ec80e2..24810b14ea27 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -7,6 +7,7 @@ "push:android:smoke": "node scripts/android-push-smoke.ts", "push:android:watch": "node scripts/android-push-watch.ts", "tunnels:census": "node scripts/tunnel-census.ts", + "sweep:preview": "node scripts/sweep-preview-tunnels.ts", "destroy": "alchemy destroy", "test": "vp test run --config ../../vite.config.ts --dir .", "typecheck": "tsc --noEmit" diff --git a/infra/relay/scripts/sweep-preview-tunnels.ts b/infra/relay/scripts/sweep-preview-tunnels.ts new file mode 100644 index 000000000000..df07814a4a8b --- /dev/null +++ b/infra/relay/scripts/sweep-preview-tunnels.ts @@ -0,0 +1,134 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +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 "../src/deploymentConfig.ts"; +import { + isPreviewStage, + isPreviewTunnelName, + isPreviewTunnelRecordName, + tunnelCnameContent, +} from "../src/previewStages.ts"; + +// `alchemy destroy` removes the Worker and its custom domain. Managed endpoint +// tunnels and their CNAMEs are created at runtime, so Alchemy does not track +// them and they must be removed here. Dry run unless APPLY=true. + +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 = ( + fetchPage: (page: number) => Effect.Effect, E, R>, +) => + Effect.gen(function* () { + const items: Array = []; + for (let page = 1; ; page += 1) { + const batch = yield* fetchPage(page); + items.push(...batch); + if (batch.length < PAGE_SIZE) return items; + } + }); + +const main = Effect.gen(function* () { + const stage = yield* Config.String("PREVIEW_STAGE"); + if (!isPreviewStage(stage)) { + return yield* Effect.die(`Refusing to sweep non-preview stage '${stage}'`); + } + const apply = yield* Config.Boolean("APPLY").pipe(Config.withDefault(false)); + const accountId = yield* Config.String("CLOUDFLARE_ACCOUNT_ID"); + const token = yield* Config.Redacted("CLOUDFLARE_API_TOKEN"); + const tunnelZoneName = yield* Config.String("RELAY_TUNNEL_ZONE_NAME"); + const client = yield* HttpClient.HttpClient; + + const send = (request: HttpClientRequest.HttpClientRequest) => + request.pipe( + HttpClientRequest.bearerToken(Redacted.value(token)), + client.execute, + Effect.flatMap(HttpClientResponse.filterStatusOk), + ); + const getJson = (url: string, params: Record, schema: Schema.Codec) => + send(HttpClientRequest.get(url, { urlParams: params })).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + ); + + const tunnelPrefix = managedEndpointTunnelNamePrefix(stage); + const tunnels = (yield* collectPages((page) => + getJson( + `${API}/accounts/${accountId}/cfd_tunnel`, + { + is_deleted: "false", + include_prefix: tunnelPrefix, + per_page: String(PAGE_SIZE), + page: String(page), + }, + TunnelPage, + ).pipe(Effect.map((body) => body.result)), + )).filter((tunnel) => tunnel.name != null && isPreviewTunnelName(tunnel.name, stage)); + const tunnelIds = new Set(tunnels.map((tunnel) => tunnel.id)); + + const zone = yield* getJson(`${API}/zones`, { name: tunnelZoneName }, ZonePage); + const zoneId = zone.result[0]?.id; + if (zoneId === undefined) { + return yield* Effect.die(`No Cloudflare zone named ${tunnelZoneName}`); + } + const records = (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)), + )).filter( + (record) => + isPreviewTunnelRecordName(record.name, stage, tunnelZoneName) && + [...tunnelIds].some((id) => record.content === tunnelCnameContent(id)), + ); + + yield* Console.log( + `${apply ? "Deleting" : "Would delete"} ${tunnels.length} tunnel(s) and ${records.length} CNAME(s) for ${stage}`, + ); + if (!apply) return; + + // Records first, so no hostname points at a tunnel that is about to vanish. + for (const record of records) { + yield* send( + HttpClientRequest.make("DELETE")(`${API}/zones/${zoneId}/dns_records/${record.id}`), + ); + } + // Cloudflare refuses to delete a tunnel that still has connectors attached. + for (const tunnel of tunnels) { + yield* send( + HttpClientRequest.make("DELETE")( + `${API}/accounts/${accountId}/cfd_tunnel/${tunnel.id}/connections`, + ), + ); + yield* send( + HttpClientRequest.make("DELETE")(`${API}/accounts/${accountId}/cfd_tunnel/${tunnel.id}`), + ); + } + yield* Console.log(`Swept ${stage}`); +}).pipe(Effect.provide(FetchHttpClient.layer)); + +NodeRuntime.runMain(main); diff --git a/infra/relay/src/previewStages.test.ts b/infra/relay/src/previewStages.test.ts new file mode 100644 index 000000000000..9e255fad98a7 --- /dev/null +++ b/infra/relay/src/previewStages.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPreviewStage, isPreviewTunnelName, isPreviewTunnelRecordName } 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); + }); +}); diff --git a/infra/relay/src/previewStages.ts b/infra/relay/src/previewStages.ts new file mode 100644 index 000000000000..f016d25c364b --- /dev/null +++ b/infra/relay/src/previewStages.ts @@ -0,0 +1,41 @@ +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_` 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 `-.`. +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`; +} From ac68c39606bb5e5c972006edfa0e33d65bb5edb4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 14:55:19 -0700 Subject: [PATCH 2/9] ci(relay): sweep preview tunnels as a stack resource on destroy The standalone sweep script ran as a separate workflow step, so teardown depended on that step and on a dry-run flag. Alchemy Actions never run on destroy, so the sweep is a Resource instead: its delete sweeps the stage's managed tunnels and their CNAMEs, and `alchemy destroy` runs it. The resource depends on the Worker, so it is deleted before the Worker. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/relay-preview-cleanup.yml | 15 +- infra/relay/alchemy.run.ts | 14 +- infra/relay/package.json | 1 - infra/relay/scripts/sweep-preview-tunnels.ts | 134 -------------- infra/relay/src/previewStages.test.ts | 31 +++- infra/relay/src/previewStages.ts | 33 ++++ infra/relay/src/previewTunnelSweep.ts | 178 +++++++++++++++++++ 7 files changed, 256 insertions(+), 150 deletions(-) delete mode 100644 infra/relay/scripts/sweep-preview-tunnels.ts create mode 100644 infra/relay/src/previewTunnelSweep.ts diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index 14b8ddb4a0d2..947313b3b32f 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -64,9 +64,7 @@ jobs: args: - --filter=t3code-relay... - # Destroy first, so the Worker that creates tunnels is gone before the - # sweep runs. If destroy fails the sweep is skipped, and the nightly - # sweep retries both. + # 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: @@ -76,13 +74,6 @@ jobs: AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - - name: Sweep preview tunnels and DNS - run: vp run --filter t3code-relay sweep:preview - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - PREVIEW_STAGE: ${{ env.STAGE }} - APPLY: "true" - sweep_orphans: name: Sweep orphaned relay previews if: github.event_name != 'pull_request_target' @@ -119,9 +110,8 @@ jobs: args: - --filter=t3code-relay... - - name: Destroy and sweep stages of closed labeled PRs + - name: Destroy stages of closed labeled PRs env: - APPLY: "true" CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} @@ -134,7 +124,6 @@ jobs: stage="pr-$number" echo "::group::$stage" vp run --filter t3code-relay destroy --stage "$stage" --yes --no-input \ - && PREVIEW_STAGE="$stage" vp run --filter t3code-relay sweep:preview \ || { echo "Cleanup failed for $stage" >&2; failed=1; } echo "::endgroup::" done diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index 3de655b89c4d..29d2a9cc6016 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -11,7 +11,9 @@ import * as Planetscale from "alchemy/Planetscale"; 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 @@ export default Alchemy.Stack( 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, diff --git a/infra/relay/package.json b/infra/relay/package.json index 24810b14ea27..6ff488ec80e2 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -7,7 +7,6 @@ "push:android:smoke": "node scripts/android-push-smoke.ts", "push:android:watch": "node scripts/android-push-watch.ts", "tunnels:census": "node scripts/tunnel-census.ts", - "sweep:preview": "node scripts/sweep-preview-tunnels.ts", "destroy": "alchemy destroy", "test": "vp test run --config ../../vite.config.ts --dir .", "typecheck": "tsc --noEmit" diff --git a/infra/relay/scripts/sweep-preview-tunnels.ts b/infra/relay/scripts/sweep-preview-tunnels.ts deleted file mode 100644 index df07814a4a8b..000000000000 --- a/infra/relay/scripts/sweep-preview-tunnels.ts +++ /dev/null @@ -1,134 +0,0 @@ -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as Config from "effect/Config"; -import * as Console from "effect/Console"; -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 "../src/deploymentConfig.ts"; -import { - isPreviewStage, - isPreviewTunnelName, - isPreviewTunnelRecordName, - tunnelCnameContent, -} from "../src/previewStages.ts"; - -// `alchemy destroy` removes the Worker and its custom domain. Managed endpoint -// tunnels and their CNAMEs are created at runtime, so Alchemy does not track -// them and they must be removed here. Dry run unless APPLY=true. - -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 = ( - fetchPage: (page: number) => Effect.Effect, E, R>, -) => - Effect.gen(function* () { - const items: Array = []; - for (let page = 1; ; page += 1) { - const batch = yield* fetchPage(page); - items.push(...batch); - if (batch.length < PAGE_SIZE) return items; - } - }); - -const main = Effect.gen(function* () { - const stage = yield* Config.String("PREVIEW_STAGE"); - if (!isPreviewStage(stage)) { - return yield* Effect.die(`Refusing to sweep non-preview stage '${stage}'`); - } - const apply = yield* Config.Boolean("APPLY").pipe(Config.withDefault(false)); - const accountId = yield* Config.String("CLOUDFLARE_ACCOUNT_ID"); - const token = yield* Config.Redacted("CLOUDFLARE_API_TOKEN"); - const tunnelZoneName = yield* Config.String("RELAY_TUNNEL_ZONE_NAME"); - const client = yield* HttpClient.HttpClient; - - const send = (request: HttpClientRequest.HttpClientRequest) => - request.pipe( - HttpClientRequest.bearerToken(Redacted.value(token)), - client.execute, - Effect.flatMap(HttpClientResponse.filterStatusOk), - ); - const getJson = (url: string, params: Record, schema: Schema.Codec) => - send(HttpClientRequest.get(url, { urlParams: params })).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), - ); - - const tunnelPrefix = managedEndpointTunnelNamePrefix(stage); - const tunnels = (yield* collectPages((page) => - getJson( - `${API}/accounts/${accountId}/cfd_tunnel`, - { - is_deleted: "false", - include_prefix: tunnelPrefix, - per_page: String(PAGE_SIZE), - page: String(page), - }, - TunnelPage, - ).pipe(Effect.map((body) => body.result)), - )).filter((tunnel) => tunnel.name != null && isPreviewTunnelName(tunnel.name, stage)); - const tunnelIds = new Set(tunnels.map((tunnel) => tunnel.id)); - - const zone = yield* getJson(`${API}/zones`, { name: tunnelZoneName }, ZonePage); - const zoneId = zone.result[0]?.id; - if (zoneId === undefined) { - return yield* Effect.die(`No Cloudflare zone named ${tunnelZoneName}`); - } - const records = (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)), - )).filter( - (record) => - isPreviewTunnelRecordName(record.name, stage, tunnelZoneName) && - [...tunnelIds].some((id) => record.content === tunnelCnameContent(id)), - ); - - yield* Console.log( - `${apply ? "Deleting" : "Would delete"} ${tunnels.length} tunnel(s) and ${records.length} CNAME(s) for ${stage}`, - ); - if (!apply) return; - - // Records first, so no hostname points at a tunnel that is about to vanish. - for (const record of records) { - yield* send( - HttpClientRequest.make("DELETE")(`${API}/zones/${zoneId}/dns_records/${record.id}`), - ); - } - // Cloudflare refuses to delete a tunnel that still has connectors attached. - for (const tunnel of tunnels) { - yield* send( - HttpClientRequest.make("DELETE")( - `${API}/accounts/${accountId}/cfd_tunnel/${tunnel.id}/connections`, - ), - ); - yield* send( - HttpClientRequest.make("DELETE")(`${API}/accounts/${accountId}/cfd_tunnel/${tunnel.id}`), - ); - } - yield* Console.log(`Swept ${stage}`); -}).pipe(Effect.provide(FetchHttpClient.layer)); - -NodeRuntime.runMain(main); diff --git a/infra/relay/src/previewStages.test.ts b/infra/relay/src/previewStages.test.ts index 9e255fad98a7..0c9270e4b603 100644 --- a/infra/relay/src/previewStages.test.ts +++ b/infra/relay/src/previewStages.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { isPreviewStage, isPreviewTunnelName, isPreviewTunnelRecordName } from "./previewStages.ts"; +import { + isPreviewStage, + isPreviewTunnelName, + isPreviewTunnelRecordName, + previewSweepTargets, + tunnelCnameContent, +} from "./previewStages.ts"; const KEY = "0123456789abcdef"; @@ -28,4 +34,27 @@ describe("preview stages", () => { 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"]); + }); }); diff --git a/infra/relay/src/previewStages.ts b/infra/relay/src/previewStages.ts index f016d25c364b..f42f93064aa2 100644 --- a/infra/relay/src/previewStages.ts +++ b/infra/relay/src/previewStages.ts @@ -39,3 +39,36 @@ export function isPreviewTunnelRecordName( 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; + readonly records: ReadonlyArray; +}): { readonly tunnels: ReadonlyArray; readonly records: ReadonlyArray } { + 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 }; +} diff --git a/infra/relay/src/previewTunnelSweep.ts b/infra/relay/src/previewTunnelSweep.ts new file mode 100644 index 000000000000..37c534d1f527 --- /dev/null +++ b/infra/relay/src/previewTunnelSweep.ts @@ -0,0 +1,178 @@ +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("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 = ( + fetchPage: (page: number) => Effect.Effect, E, R>, +) => + Effect.gen(function* () { + const items: Array = []; + 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. +const sweepPreviewStage = Effect.fn("PreviewTunnelSweep.sweep")(function* (input: { + readonly stage: string; + readonly tunnelZoneName: string; + readonly accountId: string; + readonly token: Redacted.Redacted; +}) { + 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 = (url: string, params: Record, schema: Schema.Codec) => + send(HttpClientRequest.get(url, { urlParams: params })).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + ); + + 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)), + ); + + const zone = yield* getJson(`${API}/zones`, { name: input.tunnelZoneName }, ZonePage); + const zoneId = zone.result[0]?.id; + if (zoneId === undefined) { + return yield* Effect.die(`No Cloudflare zone named ${input.tunnelZoneName}`); + } + + const listedRecords = 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. + for (const record of targets.records) { + yield* send( + HttpClientRequest.make("DELETE")(`${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* send( + HttpClientRequest.make("DELETE")( + `${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}/connections`, + ), + ); + yield* send( + HttpClientRequest.make("DELETE")( + `${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}`, + ), + ); + } +}, Effect.provide(FetchHttpClient.layer)); + +export const PreviewTunnelSweepProvider = () => + Provider.succeed( + PreviewTunnelSweep, + PreviewTunnelSweep.Provider.of({ + reconcile: Effect.fn(function* ({ news }) { + return { + 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, + }); + }), + }), + ); From 0d37f4bf2fe1fb11f08368e745cfc743e5247611 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 15:06:52 -0700 Subject: [PATCH 3/9] fix(relay): use Effect.fn for the preview page helper The lint gate rejects a reusable Effect.gen without a span name, which failed the Lint and Check jobs on #23872. The reconcile callback had no yield, so it now returns Effect.succeed directly. Co-Authored-By: Claude Haiku 5.5 --- infra/relay/src/previewTunnelSweep.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/infra/relay/src/previewTunnelSweep.ts b/infra/relay/src/previewTunnelSweep.ts index 37c534d1f527..26e84e3c507e 100644 --- a/infra/relay/src/previewTunnelSweep.ts +++ b/infra/relay/src/previewTunnelSweep.ts @@ -58,17 +58,16 @@ const DnsRecordPage = Schema.Struct({ ), }); -const collectPages = ( +const collectPages = Effect.fn("PreviewTunnelSweep.collectPages")(function* ( fetchPage: (page: number) => Effect.Effect, E, R>, -) => - Effect.gen(function* () { - const items: Array = []; - for (let page = 1; ; page += 1) { - const batch = yield* fetchPage(page); - items.push(...batch); - if (batch.length < PAGE_SIZE) return items; - } - }); +) { + const items: Array = []; + 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. const sweepPreviewStage = Effect.fn("PreviewTunnelSweep.sweep")(function* (input: { @@ -148,13 +147,12 @@ export const PreviewTunnelSweepProvider = () => Provider.succeed( PreviewTunnelSweep, PreviewTunnelSweep.Provider.of({ - reconcile: Effect.fn(function* ({ news }) { - return { + 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 }) { From 554f4562287e6b13057cae6d780337992f53bfc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 15:08:57 -0700 Subject: [PATCH 4/9] fix(ci): fail the relay preview sweep when listing PRs fails or truncates A failed gh pr list fed an empty loop and exited 0, so a run could skip every teardown silently. The fixed --limit 200 also dropped the oldest closed labeled PRs without a signal. The listing now fails the step on error and fails when it reaches the limit. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/relay-preview-cleanup.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index 947313b3b32f..1aa5582cfa7d 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -118,9 +118,18 @@ jobs: AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} run: | + # A failed listing must fail the step, not look like an empty sweep. + limit=1000 + numbers=$(gh pr list --repo "$GITHUB_REPOSITORY" --state closed \ + --label "relay:preview" --limit "$limit" --json number --jq '.[].number') \ + || { echo "Could not list closed relay:preview PRs" >&2; exit 1; } + # gh silently truncates at --limit, which would skip the oldest stages. + if [ "$(printf '%s\n' "$numbers" | grep -c .)" -ge "$limit" ]; then + echo "Reached the $limit-PR listing limit; paginate before trusting this sweep" >&2 + exit 1 + fi failed=0 - for number in $(gh pr list --repo "$GITHUB_REPOSITORY" --state closed \ - --label "relay:preview" --limit 200 --json number --jq '.[].number'); do + for number in $numbers; do stage="pr-$number" echo "::group::$stage" vp run --filter t3code-relay destroy --stage "$stage" --yes --no-input \ From e06f4f7a812a8318fd958df07b9590b648f2c4a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 15:11:17 -0700 Subject: [PATCH 5/9] fix(ci): serialize relay preview retries per PR and drop the listing cap The nightly sweep ran outside each PR's deploy lock and destroyed a stage without rechecking the PR, so a reopened PR could lose its new preview. The listing also stopped at 200 PRs, and a manual dispatch from a PR branch would have checked that branch out with the cleanup secrets. Each closed labeled PR now gets a matrix job in the relay-preview- group, the group deploys use. It confirms the PR is still closed and unlabeled before destroying, and checks out the default branch explicitly. Listing goes through the paginated issues API with no cap. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/relay-preview-cleanup.yml | 83 ++++++++++++++------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index 1aa5582cfa7d..af5b8b9904b2 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -1,7 +1,7 @@ name: Clean up relay preview # Tears down stage `pr-` when `relay:preview` is removed or the PR closes, -# and sweeps stages left behind by a failed teardown. +# and retries teardown for closed labeled PRs on a schedule. # # 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 @@ -15,6 +15,7 @@ on: permissions: contents: read + issues: read pull-requests: read concurrency: @@ -74,15 +75,50 @@ jobs: AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - sweep_orphans: - name: Sweep orphaned relay previews + list_closed_previews: + name: List closed relay previews if: github.event_name != 'pull_request_target' runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 60 + 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: 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 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 }} @@ -93,15 +129,30 @@ jobs: APNS_ENABLED: "false" ALCHEMY_TELEMETRY_DISABLED: "1" steps: + # The lock above is taken before this runs, so the state read here is + # current. A reopened or relabeled PR is left alone. + - name: Confirm the PR is still closed and unlabeled + id: eligible + run: | + state=$(gh pr view "${{ matrix.number }}" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq 'if .state == "CLOSED" and (any(.labels[]; .name == "relay:preview") | not) then "yes" else "no" end') \ + || { echo "Could not read PR ${{ matrix.number }}" >&2; exit 1; } + echo "eligible=$state" >> "$GITHUB_OUTPUT" + [ "$state" = yes ] || echo "PR ${{ matrix.number }} is open or labeled 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 @@ -110,30 +161,12 @@ jobs: args: - --filter=t3code-relay... - - name: Destroy stages of closed labeled PRs + - 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 }} - run: | - # A failed listing must fail the step, not look like an empty sweep. - limit=1000 - numbers=$(gh pr list --repo "$GITHUB_REPOSITORY" --state closed \ - --label "relay:preview" --limit "$limit" --json number --jq '.[].number') \ - || { echo "Could not list closed relay:preview PRs" >&2; exit 1; } - # gh silently truncates at --limit, which would skip the oldest stages. - if [ "$(printf '%s\n' "$numbers" | grep -c .)" -ge "$limit" ]; then - echo "Reached the $limit-PR listing limit; paginate before trusting this sweep" >&2 - exit 1 - fi - failed=0 - for number in $numbers; do - stage="pr-$number" - echo "::group::$stage" - vp run --filter t3code-relay destroy --stage "$stage" --yes --no-input \ - || { echo "Cleanup failed for $stage" >&2; failed=1; } - echo "::endgroup::" - done - exit "$failed" From 5d543a26735a081b5130cb5cf763fa73c3d31b80 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 15:13:12 -0700 Subject: [PATCH 6/9] fix(relay): let preview teardown finish when zone or tunnel is already gone A missing zone raised before any tunnel was checked, so it blocked teardown even with no tunnels left. The sweep now skips the zone lookup when nothing remains, and still removes tunnels when the zone is gone. A 404 on delete counts as swept; any other failure, such as a 429, still fails so the next run retries. Covered by tests that drive the sweep through a stubbed Cloudflare client. Co-Authored-By: Claude Haiku 5.5 --- infra/relay/src/previewTunnelSweep.test.ts | 102 +++++++++++++++++++++ infra/relay/src/previewTunnelSweep.ts | 63 +++++++------ 2 files changed, 137 insertions(+), 28 deletions(-) create mode 100644 infra/relay/src/previewTunnelSweep.test.ts diff --git a/infra/relay/src/previewTunnelSweep.test.ts b/infra/relay/src/previewTunnelSweep.test.ts new file mode 100644 index 000000000000..ce0415ed86db --- /dev/null +++ b/infra/relay/src/previewTunnelSweep.test.ts @@ -0,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 = []; + 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) => + 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); + }), + ); +}); diff --git a/infra/relay/src/previewTunnelSweep.ts b/infra/relay/src/previewTunnelSweep.ts index 26e84e3c507e..57f83ca09eac 100644 --- a/infra/relay/src/previewTunnelSweep.ts +++ b/infra/relay/src/previewTunnelSweep.ts @@ -70,7 +70,7 @@ const collectPages = Effect.fn("PreviewTunnelSweep.collectPages")(function* + 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( @@ -101,19 +113,22 @@ const sweepPreviewStage = Effect.fn("PreviewTunnelSweep.sweep")(function* (input ).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; - if (zoneId === undefined) { - return yield* Effect.die(`No Cloudflare zone named ${input.tunnelZoneName}`); - } - - const listedRecords = 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)), - ); + // 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, @@ -123,25 +138,17 @@ const sweepPreviewStage = Effect.fn("PreviewTunnelSweep.sweep")(function* (input }); // Records first, so no hostname points at a tunnel that is about to vanish. - for (const record of targets.records) { - yield* send( - HttpClientRequest.make("DELETE")(`${API}/zones/${zoneId}/dns_records/${record.id}`), - ); + 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* send( - HttpClientRequest.make("DELETE")( - `${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}/connections`, - ), - ); - yield* send( - HttpClientRequest.make("DELETE")( - `${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}`, - ), - ); + yield* remove(`${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}/connections`); + yield* remove(`${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}`); } -}, Effect.provide(FetchHttpClient.layer)); +}); export const PreviewTunnelSweepProvider = () => Provider.succeed( @@ -170,7 +177,7 @@ export const PreviewTunnelSweepProvider = () => tunnelZoneName: output.tunnelZoneName, accountId, token, - }); + }).pipe(Effect.provide(FetchHttpClient.layer)); }), }), ); From 14745631364edfc21c4ed51abd7ec89a5b09da6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 15:14:28 -0700 Subject: [PATCH 7/9] fix(ci): scope the relay teardown concurrency group to the teardown job The workflow-level group applied to every run, including closed events that skip all jobs. GitHub replaces a pending run in a group with a newer one, so a label-less close could drop a queued teardown for the same PR. The group now sits on the teardown job, which is the only job that needs it. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/relay-preview-cleanup.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index af5b8b9904b2..0e31585bd49e 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -18,10 +18,6 @@ permissions: issues: read pull-requests: read -concurrency: - group: relay-preview-${{ github.event.pull_request.number || 'sweep' }} - cancel-in-progress: false - jobs: cleanup_pr: name: Tear down relay preview @@ -31,6 +27,12 @@ jobs: (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 runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 environment: From 4a027150049c84f9e044a6d683f61b03415211e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 15:20:45 -0700 Subject: [PATCH 8/9] fix(ci): make the relay teardown retry find and finish failed previews The nightly retry listed labeled closed PRs, then required each one to be unlabeled, so no teardown ever ran. Nothing removed the label either, so every failure re-queued every night. It also tested state == CLOSED, which misses MERGED PRs. Teardown now clears the label only after a successful destroy, so the scheduled list holds exactly the outstanding failures. Eligibility is "not OPEN". Every job also requires the main branch, like deploy-relay. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/relay-preview-cleanup.yml | 38 ++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index 0e31585bd49e..09832d5dd619 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -1,7 +1,9 @@ name: Clean up relay preview # Tears down stage `pr-` when `relay:preview` is removed or the PR closes, -# and retries teardown for closed labeled PRs on a schedule. +# 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 @@ -22,6 +24,7 @@ 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') || @@ -33,11 +36,16 @@ jobs: 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 }} @@ -77,9 +85,15 @@ jobs: 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 + list_closed_previews: name: List closed relay previews - if: github.event_name != 'pull_request_target' + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request_target' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: @@ -103,7 +117,7 @@ jobs: sweep_pr: name: Retry teardown for PR ${{ matrix.number }} needs: list_closed_previews - if: needs.list_closed_previews.outputs.numbers != '[]' + if: github.ref == 'refs/heads/main' && needs.list_closed_previews.outputs.numbers != '[]' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 strategy: @@ -116,6 +130,10 @@ jobs: concurrency: group: relay-preview-${{ matrix.number }} cancel-in-progress: false + permissions: + contents: read + issues: write + pull-requests: write environment: name: relay-preview-cleanup env: @@ -132,16 +150,16 @@ jobs: ALCHEMY_TELEMETRY_DISABLED: "1" steps: # The lock above is taken before this runs, so the state read here is - # current. A reopened or relabeled PR is left alone. - - name: Confirm the PR is still closed and unlabeled + # 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,labels \ - --jq 'if .state == "CLOSED" and (any(.labels[]; .name == "relay:preview") | not) then "yes" else "no" end') \ + --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 or labeled again; leaving $STAGE alone." + [ "$state" = yes ] || echo "PR ${{ matrix.number }} is open again; leaving $STAGE alone." - name: Checkout default branch if: steps.eligible.outputs.eligible == 'yes' @@ -172,3 +190,7 @@ jobs: 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 From 91874283c2d98a56587d20cbd2a2f6c40349877d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 15:39:03 -0700 Subject: [PATCH 9/9] fix(ci): keep a failed unlabeled teardown visible to the scheduled retry An unlabeled event removes relay:preview before the teardown runs, so a failed destroy left a stage no retry could find. The job now restores the label after a failed destroy. Labels added with GITHUB_TOKEN do not start new runs, so the deploy workflow does not fire. Co-Authored-By: Claude Haiku 5.5 --- .github/workflows/relay-preview-cleanup.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index 09832d5dd619..eed067adfd77 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -91,6 +91,13 @@ jobs: 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'