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/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index e9535300104d..099b97ce7bf3 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -83,6 +83,7 @@ export class DesktopSshEnvironmentRequestError extends Data.TaggedError( readonly cause: DesktopSshEnvironmentRequestCause; readonly sshHttpStatus: number | null; }> { + // The web client matches "[ssh_http:500]" to explain version-skewed SSH pairing. override get message() { const prefix = this.sshHttpStatus === null ? "" : `[ssh_http:${this.sshHttpStatus}] `; return `${prefix}SSH remote API request failed during ${this.operation}.`; 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, 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("