From 4fef307dd0a0e9eb1a7bd610ed390b6764295a65 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 18:49:54 -0700 Subject: [PATCH 01/11] fix(mobile): keep the Duo's Home and pull request headers fixed (#24295) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/Stack.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 8c7e4709d713..d89baa82e402 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -148,7 +148,7 @@ const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Pl type AppScreenOptions = NativeStackNavigationOptions & { readonly unstable_navigationItemStyle?: "editor"; - readonly unstable_headerMinimizationBehavior?: "onScrollUp"; + readonly unstable_headerMinimizationBehavior?: "onScrollUp" | "never"; }; const GLASS_HEADER_OPTIONS: AppScreenOptions = { @@ -178,6 +178,10 @@ const THREAD_HEADER_OPTIONS: AppScreenOptions = { unstable_headerMinimizationBehavior: "onScrollUp", }; +// The Duo minimizes headers on scroll down by default. Home and the pull request lists +// stutter against it on hardware (not reproducible in the simulator), so they keep theirs. +const FIXED_DUO_HEADER: AppScreenOptions = { unstable_headerMinimizationBehavior: "never" }; + const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", @@ -649,6 +653,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: "", options: { ...GLASS_HEADER_OPTIONS, + ...FIXED_DUO_HEADER, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, ...getCompactBrandHeaderOptions(), @@ -660,7 +665,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ path: "pull-requests/:environmentId/:projectId/:repository/:number", parse: { number: Number }, }, - options: DETAIL_HEADER_OPTIONS, + options: { ...DETAIL_HEADER_OPTIONS, ...FIXED_DUO_HEADER }, }), PullRequestGuideStop: createNativeStackScreen({ screen: PullRequestGuideStopScreen, @@ -684,6 +689,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestReviewersScreen, options: { ...DETAIL_HEADER_OPTIONS, + ...FIXED_DUO_HEADER, ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: formSheetDetents(0.75), sheetGrabberVisible: true, From 7d87be359696acbc068d3c09d956acd2e4aff14b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 19:22:23 -0700 Subject: [PATCH 02/11] fix(server): interrupts started by an agent no longer say "by user" in the timeline (#24299) Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.ts | 11 +++++-- ...OrchestratorMcpToolkit.integration.test.ts | 8 +++++ .../src/orchestration-v2/Orchestrator.ts | 30 +++++++++++++++++-- .../ProviderTurnStartService.ts | 14 +++++++++ .../orchestration-v2/RunExecutionService.ts | 25 +++++++++++++++- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 2c11d6e3b6ee..8f39c2e452a6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -278,6 +278,11 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure { } } +// Without a reason, a Stop reads as the user's (see RunExecutionService.USER_STOP_REASON). +function agentStopReason(reason: string | undefined): string { + return reason === undefined ? "Stopped by an agent" : `Stopped by an agent: ${reason}`; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -2379,14 +2384,14 @@ const make = Effect.gen(function* () { ); const stopChild = Effect.gen(function* () { const commandId = stableCommandId({ scope, requestKey: key, operation: "cancel-task" }); - const reason = input.reason; + const reason = agentStopReason(input.reason); yield* threadManagement .dispatch({ type: "thread.stop", commandId, threadId: current.childThreadId, stopDelegatedTasks: false, - ...(reason === undefined ? {} : { reason }), + reason, }) .pipe( Effect.mapError((error) => @@ -2907,7 +2912,7 @@ const make = Effect.gen(function* () { }), threadId: input.threadId, ...(input.runId === undefined ? {} : { runId: input.runId }), - ...(input.reason === undefined ? {} : { reason: input.reason }), + reason: agentStopReason(input.reason), }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 09b4bd4f424d..d47fb11fe6a2 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -491,6 +491,14 @@ describe("orchestrator MCP toolkit", () => { expect(finalProjection.runs.find((run) => run.id === queuedFollowup.runId)?.status).toBe( "completed", ); + expect( + finalProjection.turnItems.find( + (item) => + item.runId === runningFollowup.runId && item.type === "run_interrupt_result", + ), + ).toMatchObject({ + message: "Stopped by an agent: Allow the queued replay follow-up to run.", + }); yield* orchestrator .streamStoredEventsFrom({ threadId: parentThreadId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 33b434169fed..d2e9333c6999 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8977,6 +8977,31 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + const runInterruptReason = Effect.fnUntraced(function* ( + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "run.interrupt" | "thread.background-work.settle" } + >, + events: Ref.Ref>, + runId: RunId, + ) { + const isRequest = ( + item: OrchestrationV2TurnItem, + ): item is Extract => + item.runId === runId && item.type === "run_interrupt_request"; + const pending = (yield* Ref.get(events)) + .flatMap((event) => (event.type === "turn-item.updated" ? [event.payload] : [])) + .findLast(isRequest); + if (pending !== undefined) return pending.message; + const { turnItems } = yield* projectionStore + .getThreadRecords(command.threadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [runId], + }) + .pipe(mapDispatchError(command)); + return turnItems.find(isRequest)?.message; + }); + // A successful Stop may have no terminal event to ingest: the session or // native turn already died, or its final write failed when storage filled. const settleInterruptedRun = Effect.fn("Orchestrator.settleInterruptedRun")(function* (input: { @@ -9127,6 +9152,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio run, rootNode, providerThread, + reason: yield* runInterruptReason(input.command, input.events, run.id), completedAt: input.now, }), }); @@ -9226,7 +9252,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completedAt: now, updatedAt: now, type: "run_interrupt_request", - message: command.reason ?? "Interrupt requested", + message: command.reason ?? RunExecutionService.USER_STOP_REASON, }, }); }); @@ -9755,7 +9781,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completedAt: now, updatedAt: now, type: "run_interrupt_request", - message: command.reason ?? "Interrupt requested", + message: command.reason ?? RunExecutionService.USER_STOP_REASON, }; const otherProviderInterrupts: Array = []; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 89ae2cc2c0c1..4eb988ec4308 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -191,6 +191,19 @@ export const layer: Layer.Layer< }), ) .pipe(Effect.catchCause(() => Effect.succeed(false))), + loadRunInterruptReason: () => + projectionStore + .getThreadRecords(input.threadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [input.runId], + }) + .pipe( + Effect.map(({ turnItems }) => { + const request = turnItems.find((item) => item.type === "run_interrupt_request"); + return request?.type === "run_interrupt_request" ? request.message : undefined; + }), + Effect.catchCause(() => Effect.succeed(undefined)), + ), }; }; @@ -1319,6 +1332,7 @@ export const layer: Layer.Layer< shouldStartProviderTurn: runControls.shouldStartProviderTurn, shouldFinalizeRun: runControls.shouldFinalizeRun, hasUnpairedRunInterruptRequest: runControls.hasUnpairedRunInterruptRequest, + loadRunInterruptReason: runControls.loadRunInterruptReason, message: { messageId: message.id, text: userText, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a2b4f1de5bcf..11ab94cefa69 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -573,6 +573,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly shouldStartProviderTurn?: () => Effect.Effect; readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; + readonly loadRunInterruptReason?: () => Effect.Effect; readonly message: ProviderAdapter.ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; @@ -621,6 +622,7 @@ export const layer: Layer.Layer< readonly attempt: OrchestrationV2RunAttempt; readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; + readonly loadRunInterruptReason?: () => Effect.Effect; readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; readonly interruptedProviderTurn?: OrchestrationV2ProviderTurn; readonly terminal: ProviderTerminalEvent; @@ -647,6 +649,10 @@ export const layer: Layer.Layer< ? false : yield* input.hasUnpairedRunInterruptRequest(); if (hasUnpairedRequest) { + const reason = + input.loadRunInterruptReason === undefined + ? undefined + : yield* input.loadRunInterruptReason(); yield* eventSink.writeWithEffects({ effects: [], events: [ @@ -663,6 +669,7 @@ export const layer: Layer.Layer< run: input.run, rootNode: input.rootNode, providerThread: input.providerThread, + reason, completedAt, }), }, @@ -674,6 +681,10 @@ export const layer: Layer.Layer< return; } const allocateEventId = () => idAllocator.allocate.event({ threadId: input.run.threadId }); + const reason = + input.terminal.status === "interrupted" && input.loadRunInterruptReason !== undefined + ? yield* input.loadRunInterruptReason() + : undefined; const open = input.openRunOwnedSubagents ?? emptyOpenRunOwnedSubagentProjection(); const hasOpenSubagentProjection = open.subagents.size > 0 || @@ -791,6 +802,7 @@ export const layer: Layer.Layer< run: input.run, rootNode: input.rootNode, providerThread: input.providerThread, + reason, completedAt, }), }, @@ -1060,6 +1072,9 @@ export const layer: Layer.Layer< : { hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, }), + ...(input.loadRunInterruptReason === undefined + ? {} + : { loadRunInterruptReason: input.loadRunInterruptReason }), openRunOwnedSubagents: openSubagents, terminal, failureItemPersisted: terminal.status === "failed", @@ -1684,11 +1699,19 @@ export const layer: Layer.Layer< }), ); +/** + * Clients send Stop without a reason; server callers acting for an agent + * always set one, so a missing reason means a person stopped the run. + */ +export const USER_STOP_REASON = "Stopped by user"; + export function makeInterruptResultTurnItem(input: { readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly run: OrchestrationV2Run; readonly rootNode: OrchestrationV2ExecutionNode; readonly providerThread: OrchestrationV2ProviderThread; + /** The Stop's own reason, from its run_interrupt_request item. */ + readonly reason: string | undefined; readonly completedAt: DateTime.Utc; }): OrchestrationV2TurnItem { return { @@ -1713,6 +1736,6 @@ export function makeInterruptResultTurnItem(input: { completedAt: input.completedAt, updatedAt: input.completedAt, type: "run_interrupt_result", - message: "Run interrupted by user", + message: input.reason ?? "Stopped by the provider", }; } From ff1f6fd8849cdd2e0a07096eac4fd4440d1ad989 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 23 Oct 2026 19:28:32 -0700 Subject: [PATCH 03/11] feat(relay): deploy on-demand preview stages for labeled PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Labeled same-repo PRs deploy stage pr- behind the relay-preview environment reviewer gate. Unlabel or close tears the stage down, and a nightly job retries failed teardowns. Runtime-created managed endpoint tunnels and CNAMEs are swept by a PreviewTunnelSweep resource. Known gaps, tracked as follow-ups: an interrupted preview deploy can leave runtime tunnels behind; repo-level relay secrets still bypass the environment gate until moved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/relay-preview-cleanup.yml | 203 ++++++++++++++++++++ .github/workflows/relay-preview.yml | 81 ++++++++ docs/operations/release.md | 8 +- infra/relay/README.md | 4 +- infra/relay/alchemy.run.ts | 14 +- infra/relay/src/previewStages.test.ts | 60 ++++++ infra/relay/src/previewStages.ts | 74 +++++++ infra/relay/src/previewTunnelSweep.test.ts | 102 ++++++++++ infra/relay/src/previewTunnelSweep.ts | 183 ++++++++++++++++++ 9 files changed, 726 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/relay-preview-cleanup.yml create mode 100644 .github/workflows/relay-preview.yml create mode 100644 infra/relay/src/previewStages.test.ts create mode 100644 infra/relay/src/previewStages.ts create mode 100644 infra/relay/src/previewTunnelSweep.test.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 new file mode 100644 index 000000000000..eed067adfd77 --- /dev/null +++ b/.github/workflows/relay-preview-cleanup.yml @@ -0,0 +1,203 @@ +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. 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 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/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/src/previewStages.test.ts b/infra/relay/src/previewStages.test.ts new file mode 100644 index 000000000000..0c9270e4b603 --- /dev/null +++ b/infra/relay/src/previewStages.test.ts @@ -0,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"]); + }); +}); diff --git a/infra/relay/src/previewStages.ts b/infra/relay/src/previewStages.ts new file mode 100644 index 000000000000..f42f93064aa2 --- /dev/null +++ b/infra/relay/src/previewStages.ts @@ -0,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_` 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`; +} + +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.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 new file mode 100644 index 000000000000..57f83ca09eac --- /dev/null +++ b/infra/relay/src/previewTunnelSweep.ts @@ -0,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("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* ( + fetchPage: (page: number) => Effect.Effect, E, R>, +) { + 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. +export 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)), + ); + // 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)); + }), + }), + ); From 26c3625f484eadd4d5b6ca5a3dd1f8f8bd5533b4 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:36:25 -0700 Subject: [PATCH 04/11] fix(mobile): HTML links in pull request comments open when tapped (#22110) --- apps/mobile/src/lib/markdownDocument.ts | 181 +++++++++++++++++++---- apps/mobile/src/lib/markdownHtml.test.ts | 34 ++++- 2 files changed, 184 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index 42f737f16b34..c398325a4920 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -55,8 +55,56 @@ function attribute(tag: string, name: string): string | null { return null; } -/** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */ -export function markdownHtmlContent(content: string): string { +function markdownDestination(url: string): string { + return `<${url.replace(/&/g, "\\&").replace(//g, "%3E").replace(/\|/g, "%7C").replace(/\n/g, "")}>`; +} + +const ANCHOR_TAG = /^<\/?a[\s/>]/i; + +/** Replacements for the anchor tags still ahead, and how many emitted links are open. */ +type AnchorState = { readonly pending: string[]; open: number }; + +// Code spans keep their text; an odd trailing backslash would escape the closing "]". +function escapeLinkLabel(text: string): string { + return text + .replace(/(`+)[\s\S]*?\1|\\*[[\]]/g, (match) => + match.startsWith("`") || match.length % 2 === 0 + ? match + : `${match.slice(0, -1)}\\${match.slice(-1)}`, + ) + .replace(/\\+$/, (slashes) => (slashes.length % 2 ? `${slashes}\\` : slashes)); +} + +// An unescaped "!" before the inserted "[" would turn the link into an image. +function bangBefore(text: string, index: number): boolean { + if (text[index - 1] !== "!") return false; + let backslashes = 0; + while (text[index - 2 - backslashes] === "\\") backslashes += 1; + return backslashes % 2 === 0; +} + +/** + * Pairs `` tags in order. Only a closed anchor with an http(s) or mailto href becomes a + * Markdown link; any other anchor keeps its text unlinked. + */ +function anchorMarkdown(tags: readonly string[]): AnchorState { + const replacements = tags.map(() => ""); + const open: Array<{ index: number; href: string | undefined }> = []; + tags.forEach((tag, index) => { + if (tag[1] !== "/") { + const href = attribute(tag, "href")?.trim(); + open.push({ index, href: href && /^(?:https?|mailto):/i.test(href) ? href : undefined }); + return; + } + const opening = open.pop(); + if (!opening?.href) return; + replacements[opening.index] = "["; + replacements[index] = `](${markdownDestination(opening.href)})`; + }); + return { pending: replacements, open: 0 }; +} + +function markdownHtmlTags(content: string): RegExpExecArray[] { // An HTML block such as
can also contain Markdown fences and code spans. const codeRanges: Array<{ start: number; end: number }> = []; const tags = [...content.matchAll(HTML_TAG)]; @@ -101,40 +149,113 @@ export function markdownHtmlContent(content: string): string { codeRanges.push({ start: opening.index, end: closing.index + closing[0].length }); index = closingIndex; } - return content.replace(HTML_TAG, (tag, offset: number) => { - if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag; - if (tag.startsWith("