name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  GIT_CONFIG_COUNT: 1
  GIT_CONFIG_KEY_0: init.defaultBranch
  GIT_CONFIG_VALUE_0: main

jobs:
  detect:
    # A pull request runs each lane only when a path the lane reads changed. A path every
    # workspace reads - the lockfile, a shared package, a workflow - runs all of them. A push to
    # main and a manual run always run every lane: `deploy-production` needs them, and a skipped
    # need would skip the deploy.
    #
    # `code` runs for every change except Markdown that no build or test reads: lint and knip cover
    # every workspace. `docs` runs for any Markdown change, so that `Check` still checks its links. `desktop` lists what it skips, not what it reads: the other apps, which the
    # Electron build does not read. `api` also reads the renderer: the Worker bundles its preview
    # and web client.
    name: Detect changed areas
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      code: ${{ steps.lanes.outputs.code }}
      docs: ${{ steps.lanes.outputs.docs }}
      desktop: ${{ steps.lanes.outputs.desktop }}
      api: ${{ steps.lanes.outputs.api }}
      remote: ${{ steps.lanes.outputs.remote }}
      storybook: ${{ steps.lanes.outputs.storybook }}
      surfaces: ${{ steps.lanes.outputs.surfaces }}
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          # A pull request checks out its merge commit. Its first parent is the base branch, so the
          # two commits hold exactly the changes the pull request brings.
          fetch-depth: 2

      - name: Select lanes
        id: lanes
        shell: bash
        env:
          EVENT_NAME: ${{ github.event_name }}
        run: |
          if [[ "$EVENT_NAME" != "pull_request" ]]; then
            changed="package.json"
          else
            # Without `--no-renames`, a moved file lists only its new path, and a move to `docs/`
            # would hide the path that a lane reads.
            changed="$(git diff --name-only --no-renames HEAD^1 HEAD)"
          fi
          # Markdown that no build or test reads changes no lane. Not every Markdown file: the API
          # bundles CHANGELOG.md, and the renderer bundles Markdown from `resources/`.
          docs='^((docs|plans|changelog\.d)/.+\.md|(.+/)?AGENTS\.md|(ANALYTICS|CODE_OF_CONDUCT|CONTRIBUTING|PRIVACY|README|SECURITY|SUPPORT)\.md)$'
          all="$changed"
          changed="$(grep -Ev "$docs" <<<"$changed" || true)"
          shared='^(package\.json|bun\.lock|bunfig\.toml|\.nvmrc|biome\.json|tsconfig[^/]*\.json|\.github/|packages/|patches/|tools/|vendor/)'
          lane() {
            if grep -Eq "$shared|$2" <<<"$changed"; then
              echo "$1=true" >> "$GITHUB_OUTPUT"
            else
              echo "$1=false" >> "$GITHUB_OUTPUT"
            fi
          }
          # The inverse, for a lane that reads most of the repository: it runs when a changed path
          # does not match the pattern.
          lane_unless() {
            if grep -Ev "$2" <<<"$changed" | grep -q .; then
              echo "$1=true" >> "$GITHUB_OUTPUT"
            else
              echo "$1=false" >> "$GITHUB_OUTPUT"
            fi
          }
          lane code '.'
          # Every Markdown file, before the filter above removed some of them.
          if grep -Eq "\.md$|^scripts/check-doc-links\.ts$" <<<"$all" || grep -Eq "$shared" <<<"$changed"; then
            echo "docs=true" >> "$GITHUB_OUTPUT"
          else
            echo "docs=false" >> "$GITHUB_OUTPUT"
          fi
          lane_unless desktop '^(apps/(auth-api|mobile|mobile-updates|site-router|status)|docker|remote)/'
          lane api '^(apps/(auth-api|site-router)|src/renderer|resources)/|^(apps/mobile/)?CHANGELOG\.md$'
          lane remote '^(remote|scripts)/'
          lane storybook '^(src|apps/(auth-api|status)|\.storybook|resources|marketplace|build)/'
          lane surfaces '^(apps/(mobile|mobile-updates|site-router|status)|remote|scripts)/'
          cat "$GITHUB_OUTPUT"

  check:
    # `check:desktop` is this job and `browser` together, in that order. The
    # browser smoke test starts the real Electron binary under xvfb on another
    # Ubuntu runner, so the two run side by side. The install here is the full
    # one: this job builds with electron-vite, which resolves the `electron`
    # package.
    #
    # Branch protection requires this job, and a skipped required check counts as passed. So it
    # runs when `detect` did not succeed, and it skips only when `detect` found no code change and
    # no Markdown change. A Markdown-only change runs only the link check.
    name: Check
    needs: detect
    if: ${{ !cancelled() && (needs.detect.result != 'success' || needs.detect.outputs.code == 'true' || needs.detect.outputs.docs == 'true') }}
    runs-on: ubuntu-latest
    timeout-minutes: 60
    env:
      CODE: ${{ needs.detect.result != 'success' || needs.detect.outputs.code == 'true' }}
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun

      - name: Check unused files and dependencies
        if: env.CODE == 'true'
        run: bun run knip:check

      - name: Check image asset directories
        if: env.CODE == 'true'
        run: bun run check:assets

      - name: Check Markdown links and anchors
        run: bun run check:doc-links

      - name: Check desktop application
        if: env.CODE == 'true'
        run: bun run check:desktop:static

      - parallel:
          - name: Check translation catalogs
            if: env.CODE == 'true'
            run: bun run i18n:check

          - name: Check ratcheted TypeScript options
            if: env.CODE == 'true'
            run: bun run types:ratchet

          - name: Check agent context budget
            if: env.CODE == 'true'
            run: bun run check:agent-context

          - name: Check translated What's new notes
            if: env.CODE == 'true'
            run: bun scripts/whats-new-translations.ts

  browser:
    # The smoke test starts the real Electron binary and opens windows, so it
    # needs a display. Ubuntu runners have none, so it runs under xvfb, where
    # the queue is shorter and the boot faster than a macOS runner. The install
    # is the default full one: it downloads the Electron binary the test
    # starts, which `development-checks.md` measures as faster than restoring
    # a binary cache.
    name: Browser smoke
    needs: detect
    if: needs.detect.outputs.desktop == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - parallel:
          - name: Set up Bun
            uses: ./.github/actions/setup-bun

          - name: Install a virtual display
            shell: bash
            run: |
              sudo apt-get update
              sudo apt-get install --no-install-recommends --yes xvfb

      - name: Run the browser smoke test
        run: xvfb-run -a bun run test:browser

      - name: Run the agent browser session E2E
        run: xvfb-run -a bun run test:browser-agent-session

      - name: Run the browser steps E2E
        run: xvfb-run -a bun run test:browser-steps

      - name: Run the browser dialogs E2E
        run: xvfb-run -a bun run test:browser-dialogs

      - name: Run the browser fingerprint E2E
        run: xvfb-run -a bun run test:browser-fingerprint

      - name: Run the browser ambiguous targets E2E
        run: xvfb-run -a bun run test:browser-ambiguous-targets

  tests-desktop:
    # ubuntu-latest gives 4 vCPUs, so vitest runs 3 workers and the job is
    # CPU-bound. The suite is split across shards that run as their own
    # runners. The split is by recorded duration rather than vitest's own file-size one - see
    # tools/vitest/balanced-sequencer.ts for why, and run `bun run
    # test:durations` when the two shards below stop finishing together.
    name: Tests (desktop ${{ matrix.shard }}/2)
    needs: detect
    if: needs.detect.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2]
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Run desktop tests
        run: bun run test:desktop -- --shard=${{ matrix.shard }}/2

  signal-e2e:
    # Starts the real Signal service with throwaway secrets and checks its secret and
    # authentication rules over HTTP and WebSocket. It needs no external service.
    name: Signal E2E
    needs: detect
    if: needs.detect.outputs.remote == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Run the Signal E2E test
        run: bun run test:signal

      - name: Upload the Signal E2E report
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: signal-security-e2e
          path: .openbot-build/signal-security-e2e/
          if-no-files-found: ignore

  automation-cli-e2e:
    # Runs `openbot-cli` with Node against the real Local scripts server on a loopback port, with
    # an in-memory agent service. It needs no Electron, display or external service.
    name: Local scripts CLI E2E
    needs: detect
    if: needs.detect.outputs.desktop == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Run the Local scripts CLI E2E test
        run: bun run test:automation-cli

      - name: Upload the Local scripts CLI E2E report
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: automation-cli-e2e
          path: .openbot-build/automation-cli-e2e/
          if-no-files-found: ignore

  team-api-routes-e2e:
    # Drives the real Team API server over HTTP on a loopback port, with the real team store and
    # direct-message store on temporary files and an in-memory agent service. It needs no Electron,
    # display or external service.
    name: Team API routes E2E
    needs: detect
    if: needs.detect.outputs.desktop == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Run the Team API routes E2E test
        run: bun run test:team-api-routes

      - name: Upload the Team API routes E2E report
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: team-api-routes-e2e
          path: .openbot-build/team-api-routes-e2e/
          if-no-files-found: ignore

  reverse-actions-e2e:
    # Runs the released Team API adapters and the reverse actions (revoke and reconnect, mute and
    # unmute, pause and resume, hide and restore) against the real host services on a temporary
    # profile, with loopback HTTP and WebSocket. It also starts the host stores on settings files that
    # a power loss filled with zeros. It needs no Electron, display or external service.
    name: Team API and reverse actions E2E
    needs: detect
    if: needs.detect.outputs.desktop == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Run the Team API legacy clients E2E test
        if: ${{ !cancelled() }}
        run: bun run test:team-legacy-clients

      - name: Run the Team session revoke and reconnect E2E test
        if: ${{ !cancelled() }}
        run: bun run test:team-session-revoke

      - name: Run the sign-in relay paste E2E test
        if: ${{ !cancelled() }}
        run: bun run test:sign-in-relay-paste

      - name: Run the sign-in relay replay E2E test
        if: ${{ !cancelled() }}
        run: bun run test:sign-in-relay-replay

      - name: Run the MCP member access E2E test
        if: ${{ !cancelled() }}
        run: bun run test:mcp-member-access

      - name: Run the server mute and unmute E2E test
        if: ${{ !cancelled() }}
        run: bun run test:server-mute

      - name: Run the desktop and phone notification E2E test
        if: ${{ !cancelled() }}
        run: bun run test:desktop-notifications

      - name: Run the phone notification away and Live Activity E2E test
        if: ${{ !cancelled() }}
        run: bun run test:push-notification-away

      - name: Run the computer activity phone notification E2E test
        if: ${{ !cancelled() }}
        run: bun run test:computer-activity

      - name: Run the notification summary E2E test
        if: ${{ !cancelled() }}
        run: bun run test:notification-summary

      - name: Run the side thread notification and Live Activity link E2E test
        if: ${{ !cancelled() }}
        run: bun run test:thread-notifications

      - name: Run the routine pause and resume E2E test
        if: ${{ !cancelled() }}
        run: bun run test:routine-pause-resume

      - name: Run the routine time zone move E2E test
        if: ${{ !cancelled() }}
        run: bun run test:routine-timezone-move

      - name: Run the routine overnight window E2E test
        if: ${{ !cancelled() }}
        run: bun run test:routine-overnight-window

      - name: Run the agent open-wait tools E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-waits-tools

      - name: Run the agent hide and restore E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-hide-restore

      - name: Run the messaging pause and resume E2E test
        if: ${{ !cancelled() }}
        run: bun run test:messaging-pause-resume

      - name: Run the Discord role list and reply context E2E test
        if: ${{ !cancelled() }}
        run: bun run test:messaging-discord-access

      - name: Run the Discord embeds E2E test
        if: ${{ !cancelled() }}
        run: bun run test:messaging-discord-embeds

      - name: Run the place history E2E test
        if: ${{ !cancelled() }}
        run: bun run test:messaging-place-history

      - name: Run the Telegram answer mentions E2E test
        if: ${{ !cancelled() }}
        run: bun run test:messaging-telegram-mentions

      - name: Run the Slack ping reply E2E test
        if: ${{ !cancelled() }}
        run: bun run test:messaging-slack-ping-reply

      - name: Run the turn input E2E test
        if: ${{ !cancelled() }}
        run: bun run test:turn-input

      - name: Run the zero-filled settings file E2E test
        if: ${{ !cancelled() }}
        run: bun run test:zero-filled-settings

      - name: Run the server key rotation E2E test
        if: ${{ !cancelled() }}
        run: bun run test:host-key-rotation

      - name: Run the provider update during a turn E2E test
        if: ${{ !cancelled() }}
        run: bun run test:provider-update-busy

      - name: Run the provider update after an app upgrade E2E test
        if: ${{ !cancelled() }}
        run: bun run test:provider-update-after-app-upgrade

      - name: Run the provider CLI removal E2E test
        if: ${{ !cancelled() }}
        run: bun run test:provider-runtime-remove

      - name: Run the provider session after a process exit E2E test
        if: ${{ !cancelled() }}
        run: bun run test:provider-exit-session

      - name: Run the ACP subagent card E2E test
        if: ${{ !cancelled() }}
        run: bun run test:acp-subagents

      - name: Run the Codex subagent steps E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-subagent-steps

      - name: Run the subagent status and failure alert E2E test
        if: ${{ !cancelled() }}
        run: bun run test:subagent-status

      - name: Run the hung Codex app-server E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-hung-app-server

      - name: Run the Codex mid-turn compaction E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-midturn-compaction

      - name: Run the Codex missing rollout backfill E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-missing-rollout

      - name: Run the Codex idle thread release E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-idle-thread-release

      - name: Run the Codex usage metering E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-usage-metering

      - name: Run the Codex usage reset E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-usage-reset

      - name: Run the Codex model capacity retry E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-capacity-retry

      - name: Run the Codex warning banner E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-warning-banner

      - name: Run the usage read pacing E2E test
        if: ${{ !cancelled() }}
        run: bun run test:usage-read-pacing

      - name: Run the routine session E2E test
        if: ${{ !cancelled() }}
        run: bun run test:routine-fresh-session

      - name: Run the routine failure report E2E test
        if: ${{ !cancelled() }}
        run: bun run test:routine-report-failure

      - name: Run the agent webhook routine E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-webhook-routine

      - name: Run the agent channel management E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-channel-manage

      - name: Run the agent channel creation E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-channel-create

      - name: Run the Library folders agent tool E2E test
        if: ${{ !cancelled() }}
        run: bun run test:library-folders

      - name: Run the agent workflow gate E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-workflow-gate

      - name: Run the pinned message agent tool E2E test
        if: ${{ !cancelled() }}
        run: bun run test:pinned-message-agent-tool

      - name: Run the messaging owner card E2E test
        if: ${{ !cancelled() }}
        run: bun run test:messaging-owner-card

      - name: Run the agent profile accounts E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-profile-accounts

      - name: Run the browser profile agents E2E test
        if: ${{ !cancelled() }}
        run: bun run test:browser-profile-agents

      - name: Run the Claude usage after a resume E2E test
        if: ${{ !cancelled() }}
        run: bun run test:claude-usage-resume

      - name: Run the Claude background hold at low memory E2E test
        if: ${{ !cancelled() }}
        run: bun run test:claude-background-hold

      - name: Run the Claude turn context after a runtime refresh E2E test
        if: ${{ !cancelled() }}
        run: bun run test:claude-turn-context

      - name: Run the Claude late steer E2E test
        if: ${{ !cancelled() }}
        run: bun run test:claude-late-steer

      - name: Run the provider session reuse E2E test
        if: ${{ !cancelled() }}
        run: bun run test:provider-session-reuse

      - name: Run the backup model failover E2E test
        if: ${{ !cancelled() }}
        run: bun run test:backup-model-failover

      - name: Run the Codex empty lead turn E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex-empty-lead-turn

      - name: Run the ZIP attachment E2E test
        if: ${{ !cancelled() }}
        run: bun run test:zip-attachments

      - name: Run the MP4 attachment E2E test
        if: ${{ !cancelled() }}
        run: bun run test:mp4-attachments

      - name: Run the SVG attachment E2E test
        if: ${{ !cancelled() }}
        run: bun run test:svg-attachments

      - name: Run the any file attachment E2E test
        if: ${{ !cancelled() }}
        run: bun run test:any-file-attachments

      - name: Run the channel notifications and channel settings E2E test
        if: ${{ !cancelled() }}
        run: bun run test:channel-notifications

      - name: Run the agent MCP server tools E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-mcp-tools

      - name: Run the MCP sign-in client refusal E2E test
        if: ${{ !cancelled() }}
        run: bun run test:mcp-sign-in-client-refused

      - name: Run the MCP sign-in client metadata document E2E test
        if: ${{ !cancelled() }}
        run: bun run test:mcp-sign-in-client-metadata-document

      - name: Run the unreadable MCP server row E2E test
        if: ${{ !cancelled() }}
        run: bun run test:mcp-server-unreadable-row

      - name: Run the agent bulk settings E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-update-agents

      - name: Run the Gemini usage E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity-usage

      - name: Run the Gemini accounts E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity-accounts

      - name: Run the Gemini usage metering E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity-usage-metering

      - name: Run the Gemini quota failover E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity-quota-failover

      - name: Run the Gemini Windows zip E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity-windows-zip

      - name: Run the workflow model steps E2E test
        if: ${{ !cancelled() }}
        run: bun run test:workflow-model-steps

      - name: Run the workflow Discord E2E test
        if: ${{ !cancelled() }}
        run: bun run test:workflow-discord

      - name: Run the workflow Telegram E2E test
        if: ${{ !cancelled() }}
        run: bun run test:workflow-telegram

      - name: Run the workflow Slack E2E test
        if: ${{ !cancelled() }}
        run: bun run test:workflow-slack

      - name: Run the workflow channel output E2E test
        if: ${{ !cancelled() }}
        run: bun run test:workflow-channel-output

      - name: Run the workflow agent tools E2E test
        if: ${{ !cancelled() }}
        run: bun run test:workflow-agent-tools

      - name: Run the workflow join E2E test
        if: ${{ !cancelled() }}
        run: bun run test:workflow-join

      - name: Run the Discord files to GitHub E2E test
        if: ${{ !cancelled() }}
        run: bun run test:discord-github-files

      - name: Run the OpenCode usage metering E2E test
        if: ${{ !cancelled() }}
        run: bun run test:opencode-usage-metering

      - name: Run the Claude API key E2E test
        if: ${{ !cancelled() }}
        run: bun run test:claude-api-key

      - name: Run the Claude stored sign-in E2E test
        if: ${{ !cancelled() }}
        run: bun run test:claude-stored-sign-in

      - name: Run the Cursor usage E2E test
        if: ${{ !cancelled() }}
        run: bun run test:cursor-usage

      - name: Run the OpenCode Go usage E2E test
        if: ${{ !cancelled() }}
        run: bun run test:opencode-go-usage

      - name: Run the Pi sign-in outside OpenBot E2E test
        if: ${{ !cancelled() }}
        run: bun run test:pi-sign-in-watch

      - name: Run the provider launch order E2E test
        if: ${{ !cancelled() }}
        run: bun run test:provider-launch-order

      - name: Run the agent API key E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-api-keys

      - name: Run the remote connect analytics E2E test
        if: ${{ !cancelled() }}
        run: bun run test:remote-connect-analytics

      - name: Run the memory review E2E test
        if: ${{ !cancelled() }}
        run: bun run test:memory-review

      - name: Run the local skill revision E2E test
        if: ${{ !cancelled() }}
        run: bun run test:skill-revision

      - name: Upload the reports
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: reverse-actions-e2e
          path: |
            .openbot-build/team-api-legacy-clients-e2e/
            .openbot-build/team-session-revoke-e2e/
            .openbot-build/sign-in-relay-paste-e2e/
            .openbot-build/sign-in-relay-replay-e2e/
            .openbot-build/mcp-member-access-e2e/
            .openbot-build/server-mute-e2e/
            .openbot-build/desktop-notifications-e2e/
            .openbot-build/push-notification-away-e2e/
            .openbot-build/computer-activity-e2e/
            .openbot-build/notification-summary-e2e/
            .openbot-build/thread-notifications-e2e/
            .openbot-build/routine-pause-resume-e2e/
            .openbot-build/routine-timezone-move-e2e/
            .openbot-build/routine-overnight-window-e2e/
            .openbot-build/agent-waits-tools-e2e/
            .openbot-build/agent-hide-restore-e2e/
            .openbot-build/messaging-pause-resume-e2e/
            .openbot-build/messaging-discord-access-e2e/
            .openbot-build/messaging-discord-embeds-e2e/
            .openbot-build/messaging-place-history-e2e/
            .openbot-build/messaging-telegram-mentions-e2e/
            .openbot-build/messaging-slack-ping-reply-e2e/
            .openbot-build/turn-input-e2e/
            .openbot-build/zero-filled-settings-e2e/
            .openbot-build/provider-update-busy-e2e/
            .openbot-build/provider-update-after-app-upgrade-e2e/
            .openbot-build/provider-runtime-remove-e2e/
            .openbot-build/provider-exit-session-e2e/
            .openbot-build/acp-subagents-e2e/
            .openbot-build/codex-subagent-steps-e2e/
            .openbot-build/subagent-status-e2e/
            .openbot-build/codex-hung-app-server-e2e/
            .openbot-build/codex-midturn-compaction-e2e/
            .openbot-build/codex-missing-rollout-e2e/
            .openbot-build/codex-idle-thread-release-e2e/
            .openbot-build/codex-usage-metering-e2e/
            .openbot-build/codex-usage-reset-e2e/
            .openbot-build/codex-capacity-retry-e2e/
            .openbot-build/usage-read-pacing-e2e/
            .openbot-build/routine-fresh-session-e2e/
            .openbot-build/routine-report-failure-e2e/
            .openbot-build/agent-webhook-routine-e2e/
            .openbot-build/agent-channel-manage-e2e/
            .openbot-build/agent-channel-create-e2e/
            .openbot-build/library-folders-e2e/
            .openbot-build/agent-workflow-gate-e2e/
            .openbot-build/pinned-message-agent-tool-e2e/
            .openbot-build/messaging-owner-card-e2e/
            .openbot-build/agent-profile-accounts-e2e/
            .openbot-build/browser-profile-agents-e2e/
            .openbot-build/claude-usage-resume-e2e/
            .openbot-build/claude-background-hold-e2e/
            .openbot-build/claude-turn-context-e2e/
            .openbot-build/claude-late-steer-e2e/
            .openbot-build/provider-session-reuse-e2e/
            .openbot-build/backup-model-failover-e2e/
            .openbot-build/codex-empty-lead-turn-e2e/
            .openbot-build/zip-attachment-e2e/
            .openbot-build/mp4-attachment-e2e/
            .openbot-build/svg-attachment-e2e/
            .openbot-build/any-file-attachment-e2e/
            .openbot-build/agent-mcp-tools-e2e/
            .openbot-build/mcp-server-unreadable-row-e2e/
            .openbot-build/agent-update-agents-e2e/
            .openbot-build/antigravity-usage-e2e/
            .openbot-build/antigravity-accounts-e2e/
            .openbot-build/antigravity-usage-metering-e2e/
            .openbot-build/antigravity-quota-failover-e2e/
            .openbot-build/workflow-model-steps-e2e/
            .openbot-build/workflow-discord-e2e/
            .openbot-build/workflow-telegram-e2e/
            .openbot-build/workflow-slack-e2e/
            .openbot-build/workflow-channel-output-e2e/
            .openbot-build/workflow-agent-tools-e2e/
            .openbot-build/workflow-join-e2e/
            .openbot-build/discord-github-files-e2e/
            .openbot-build/opencode-usage-metering-e2e/
            .openbot-build/claude-api-key-e2e/
            .openbot-build/claude-stored-sign-in-e2e/
            .openbot-build/cursor-usage-e2e/
            .openbot-build/opencode-go-usage-e2e/
            .openbot-build/pi-sign-in-watch-e2e/
            .openbot-build/provider-launch-order-e2e/
            .openbot-build/agent-api-keys-e2e/
            .openbot-build/remote-connect-analytics-e2e/
            .openbot-build/memory-review-e2e/
            .openbot-build/skill-revision-e2e.json
          if-no-files-found: ignore

  provider-fakes-e2e:
    # Runs the real ACP provider drivers (custom agents, OpenCode, Cline) over fake agents from
    # `scripts/provider-fakes/`. The fakes speak ACP on stdio as the released CLIs do, or replay a
    # recorded transcript of a released CLI, so the job needs no provider account, network or display.
    # It also runs the agent database host start test, which starts the real host with Node.
    name: Provider fakes E2E
    needs: detect
    if: needs.detect.outputs.desktop == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Run the custom ACP agents E2E test
        if: ${{ !cancelled() }}
        run: bun run test:custom-agents

      - name: Run the custom ACP agent login shell PATH E2E test
        if: ${{ !cancelled() }}
        run: bun run test:custom-agents:login-path

      - name: Run the Codex login shell PATH E2E test
        if: ${{ !cancelled() }}
        run: bun run test:codex:login-path

      - name: Run the Antigravity login shell PATH E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity:login-path

      - name: Run the Antigravity MCP load failure E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity-mcp-load-failure

      - name: Run the Antigravity expired token E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity-expired-token

      - name: Run the Antigravity tool filter E2E test
        if: ${{ !cancelled() }}
        run: bun run test:antigravity-tool-filter

      - name: Run the ACP additional directories E2E test
        if: ${{ !cancelled() }}
        run: bun run test:acp-directories

      - name: Run the ACP flavors E2E test
        if: ${{ !cancelled() }}
        run: bun run test:acp-flavors

      - name: Run the ACP MCP server names E2E test
        if: ${{ !cancelled() }}
        run: bun run test:acp-mcp-names

      - name: Run the Copilot MCP relay E2E test
        if: ${{ !cancelled() }}
        run: bun run test:copilot-mcp-relay

      - name: Run the ACP turn stall E2E test
        if: ${{ !cancelled() }}
        run: bun run test:acp-turn-stall

      - name: Run the agent database host start E2E test
        if: ${{ !cancelled() }}
        run: bun run test:agent-database-host-start

      - name: Run the ACP instructions once E2E test
        if: ${{ !cancelled() }}
        run: bun run test:acp-instructions-once

      - name: Run the ACP audio input E2E test
        if: ${{ !cancelled() }}
        run: bun run test:acp-audio-input

      - name: Run the Grok steer E2E test
        if: ${{ !cancelled() }}
        run: bun run test:grok-steer

      - name: Run the Grok question E2E test
        if: ${{ !cancelled() }}
        run: bun run test:grok-question

      - name: Run the Grok partial model list E2E test
        if: ${{ !cancelled() }}
        run: bun run test:grok-partial-model-list

      - name: Run the Pi steer E2E test
        if: ${{ !cancelled() }}
        run: bun run test:pi-steer

      - name: Run the Pi idle release E2E test
        if: ${{ !cancelled() }}
        run: bun run test:pi-idle-release

      - name: Run the Muse instructions once E2E test
        if: ${{ !cancelled() }}
        run: bun run test:muse-instructions-once

      - name: Run the provider conformance E2E test
        if: ${{ !cancelled() }}
        run: bun run test:provider-conformance

      - name: Run the OpenCode expired key E2E test
        if: ${{ !cancelled() }}
        run: bun run test:opencode-expired-key

      - name: Run the OpenCode downgraded session E2E test
        if: ${{ !cancelled() }}
        run: bun run test:opencode-downgraded-session

      - name: Run the OpenCode service failure E2E test
        if: ${{ !cancelled() }}
        run: bun run test:opencode-service-failure

      - name: Run the OpenCode thought interleave E2E test
        if: ${{ !cancelled() }}
        run: bun run test:opencode-thought-interleave

      - name: Run the OpenCode background turn E2E test
        if: ${{ !cancelled() }}
        run: bun run test:opencode-background-turn

      - name: Run the Cursor stream closed E2E test
        if: ${{ !cancelled() }}
        run: bun run test:cursor-stream-closed

      - name: Run the Cursor model set E2E test
        if: ${{ !cancelled() }}
        run: bun run test:cursor-model-set

      - name: Run the Cline free models E2E test
        if: ${{ !cancelled() }}
        run: bun run test:cline-free-models

      - name: Run the recorded provider transcripts E2E test
        if: ${{ !cancelled() }}
        run: bun run test:provider-transcript-replay

      - name: Upload the reports
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: provider-fakes-e2e
          path: |
            .openbot-build/custom-acp-agent-e2e/
            .openbot-build/custom-acp-agent-login-path-e2e/
            .openbot-build/codex-login-path-e2e/
            .openbot-build/antigravity-login-path-e2e/
            .openbot-build/antigravity-mcp-load-failure-e2e/
            .openbot-build/antigravity-expired-token-e2e/
            .openbot-build/antigravity-tool-filter-e2e/
            .openbot-build/acp-additional-directories-e2e/
            .openbot-build/acp-flavor-e2e/
            .openbot-build/acp-mcp-server-names-e2e/
            .openbot-build/copilot-mcp-relay-e2e/
            .openbot-build/acp-turn-stall-e2e/
            .openbot-build/agent-database-host-start-e2e/
            .openbot-build/acp-instructions-once-e2e/
            .openbot-build/acp-audio-input-e2e/
            .openbot-build/grok-steer-e2e/
            .openbot-build/grok-question-e2e/
            .openbot-build/grok-partial-model-list-e2e/
            .openbot-build/pi-steer-e2e/
            .openbot-build/pi-idle-release-e2e/
            .openbot-build/muse-instructions-once-e2e/
            .openbot-build/provider-conformance-e2e/
            .openbot-build/opencode-expired-key-e2e/
            .openbot-build/opencode-downgraded-session-e2e/
            .openbot-build/opencode-service-failure-e2e/
            .openbot-build/opencode-thought-interleave-e2e/
            .openbot-build/opencode-background-turn-e2e/
            .openbot-build/cursor-stream-closed-e2e/
            .openbot-build/cursor-model-set-e2e/
            .openbot-build/cline-free-models-e2e/
            .openbot-build/provider-transcript-replay-e2e/
          if-no-files-found: ignore

  surfaces:
    # `desktop:typecheck` covers only the desktop projects, so without this job
    # `apps/mobile`, `apps/site-router`, `packages/team-client`, `remote/api`,
    # `packages/logging`, `packages/user-errors` and `packages/i18n`
    # first break at release time rather than on the pull request that broke
    # them. Mobile's typecheck generates `expo-env.d.ts` and the uniwind
    # declarations first - both are gitignored, so a fresh checkout cannot
    # resolve `global.css`. `remote/api` is typechecked through the workspace's
    # own script; its `include` reaches `../scripts/*.ts`, so the stack scripts
    # come with it. `remote:check:compose` is the rest of
    # `remote:check`: `docker compose config` interpolates client-side and never
    # opens a socket, so it validates both Compose files on a runner with no
    # daemon.
    name: Surfaces
    needs: detect
    if: needs.detect.outputs.surfaces == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - parallel:
          - name: Typecheck mobile
            run: bun run mobile:typecheck

          - name: Typecheck team client
            run: bun run typecheck:team-client

      - parallel:
          - name: Typecheck site router
            run: bun run typecheck:sites

          - name: Typecheck mobile update server
            run: bun run typecheck:updates

          - name: Typecheck remote API
            run: bun run typecheck:remote

          - name: Typecheck status page
            run: bun run typecheck:status

      - parallel:
          - name: Typecheck logging
            run: bun run typecheck:logging

          - name: Typecheck telemetry
            run: bun run typecheck:telemetry

          - name: Typecheck user errors
            run: bun run typecheck:user-errors

          - name: Typecheck i18n
            run: bun run typecheck:i18n

      - name: Validate the remote Compose files
        run: bun run remote:check:compose

  mobile-share-sender-e2e:
    # Builds the page that the iOS share extension runs and drives it in the runner's Chrome, as the
    # extension's web view does. It needs no host, display or external service.
    name: Share extension page E2E
    needs: detect
    if: needs.detect.outputs.surfaces == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Run the share extension page E2E test
        run: bun run test:mobile-share-sender

      - name: Upload the share extension page E2E report
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: mobile-share-sender-e2e
          path: .openbot-build/mobile-share-sender-e2e/report.json
          if-no-files-found: ignore

  mobile-ota-e2e:
    # Runs the update server of the mobile app on a local bucket and the real publish commands, then
    # asks for updates as a phone does. It needs no device and no external service.
    name: Mobile update server E2E
    needs: detect
    if: needs.detect.outputs.surfaces == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Run the mobile update server E2E test
        run: bun run test:mobile-ota

      # The release notes of an update, and the store release steps that must ignore its section.
      # The notes test makes commits in a temporary repository.
      - name: Run the mobile OTA release notes E2E test
        env:
          GIT_AUTHOR_NAME: ci
          GIT_AUTHOR_EMAIL: ci@example.invalid
          GIT_COMMITTER_NAME: ci
          GIT_COMMITTER_EMAIL: ci@example.invalid
        run: bun run test:mobile-ota-notes

      - name: Run the mobile release announcement E2E test
        run: bun run test:mobile-release-announcement

      - name: Upload the mobile update E2E reports
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: mobile-ota-e2e
          path: |
            .openbot-build/mobile-ota-e2e/report.json
            .openbot-build/mobile-ota-release-notes-e2e.json
            .openbot-build/mobile-release-announcement-e2e.json
          if-no-files-found: ignore

  mobile-runtime:
    # Says when a pull request makes a new native runtime version of the mobile app. Such a change
    # reaches phones only with a store build, and after it `main` cannot go over the air to the
    # current store build. It is a notice, not a required check: `bun run mobile:ota:publish`
    # refuses such a commit in any case. See apps/mobile/README.md#over-the-air-updates.
    name: Mobile runtime version
    needs: detect
    if: github.event_name == 'pull_request' && needs.detect.outputs.surfaces == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          # The merge commit and the base branch commit, its first parent.
          fetch-depth: 2

      # Most pull requests change no file that the native build reads. They need no comparison.
      - name: Find changes that can reach the native build
        id: inputs
        shell: bash
        run: |
          if git diff --name-only --no-renames HEAD^1 HEAD | grep -Eq '^(apps/mobile/(app\.json|app\.config\.js|eas\.json|package\.json|fingerprint\.config\.js|\.gitignore|modules/|plugins/|certs/|assets/|src/features/share/extension/)|patches/|bun\.lock$|scripts/mobile-share-sender\.ts$)'; then
            echo "changed=true" >> "$GITHUB_OUTPUT"
          else
            echo "changed=false" >> "$GITHUB_OUTPUT"
            echo "Mobile runtime: no change. No file that the native build reads changed." >> "$GITHUB_STEP_SUMMARY"
          fi

      - name: Set up Bun
        if: steps.inputs.outputs.changed == 'true'
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Install the base commit
        if: steps.inputs.outputs.changed == 'true'
        shell: bash
        run: |
          git worktree add --detach "$RUNNER_TEMP/base" HEAD^1
          cd "$RUNNER_TEMP/base"
          bun install --frozen-lockfile --ignore-scripts

      - name: Compare the runtime versions
        if: steps.inputs.outputs.changed == 'true'
        run: bun scripts/mobile-ota.ts compare-runtime --base-dir "$RUNNER_TEMP/base"

  api:
    # `check:api` ends with `api:build:check`, the preview build of the Worker. For a pull request
    # from this repository, this job uploads that build. It has no write token, and its only secret
    # is the read-only key of the private home page beam repository (see `LandingBeam.tsx`). The
    # trusted `cloudflare-preview.yml` workflow uploads the build and comments on the pull request
    # after this run completes; it runs code from `main` only. Forks get no preview. A pull request
    # that skips this lane, or fails it, keeps the preview of its last commit that built one.
    name: API
    needs: detect
    if: needs.detect.outputs.api == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      # Forks and Dependabot get no key. They build the home page with no beam.
      - name: Look for the home page beam key
        id: beam
        env:
          LASER_FLOW_DEPLOY_KEY: ${{ secrets.LASER_FLOW_DEPLOY_KEY }}
        run: echo "present=$([ -n "$LASER_FLOW_DEPLOY_KEY" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"

      - name: Check out the home page beam
        if: steps.beam.outputs.present == 'true'
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          repository: nightly-labs/openbot-laser-flow
          ssh-key: ${{ secrets.LASER_FLOW_DEPLOY_KEY }}
          path: apps/auth-api/src/vendor/laser-flow
          persist-credentials: false

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Check API
        run: bun run check:api

      - name: Upload preview build
        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: cloudflare-preview
          path: apps/auth-api/dist
          # The build writes `.assetsignore`, which keeps the config out of the public assets.
          include-hidden-files: true
          if-no-files-found: error
          retention-days: 3

  storybook:
    name: Storybook build
    needs: detect
    if: needs.detect.outputs.storybook == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set up Bun
        uses: ./.github/actions/setup-bun
        with:
          install: ignore-scripts

      - name: Build Storybook
        run: bun run build-storybook
        env:
          # This output is not published. Skip Solid's per-component docgen analysis.
          OPENBOT_STORYBOOK_CHECK: "true"

  all-checks-pass:
    # A summary of every job. Branch protection requires `Check`, not this job. A lane that
    # `detect` skipped counts as passed; a failed or cancelled job, including `detect` itself,
    # fails this one.
    name: All required checks pass
    if: always()
    needs: [detect, check, browser, tests-desktop, signal-e2e, automation-cli-e2e, team-api-routes-e2e, reverse-actions-e2e, provider-fakes-e2e, surfaces, mobile-share-sender-e2e, mobile-ota-e2e, api, storybook]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Fail on a failed or cancelled job
        shell: bash
        env:
          RESULTS: ${{ join(needs.*.result, ' ') }}
        run: |
          for result in $RESULTS; do
            case "$result" in
              success | skipped) ;;
              *)
                echo "A required job ended as $result." >&2
                exit 1
                ;;
            esac
          done

  deploy-production:
    name: Deploy Cloudflare production
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    # `surfaces` gates the deploy even though the Worker it ships lives in
    # `apps/auth-api`, which `api` already covers: mobile, the site router, the
    # team client and the remote stack all consume the account API, so a red
    # Surfaces is the signal that this deploy is the thing about to break them.
    needs: [check, browser, tests-desktop, signal-e2e, automation-cli-e2e, team-api-routes-e2e, reverse-actions-e2e, provider-fakes-e2e, api, storybook, surfaces, mobile-share-sender-e2e]
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment: cloudflare-production
    # Template selection changes Worker settings. Do not race a Worker deployment.
    concurrency:
      group: cloudflare-production-worker
      cancel-in-progress: false
      queue: max
    permissions:
      contents: read
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      # Production always has the home page beam. Without the key, the deploy stops here.
      - name: Check out the home page beam
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          repository: nightly-labs/openbot-laser-flow
          ssh-key: ${{ secrets.LASER_FLOW_DEPLOY_KEY }}
          path: apps/auth-api/src/vendor/laser-flow
          persist-credentials: false

      - name: Set up Bun
        uses: ./.github/actions/setup-bun

      - name: Build production Worker
        run: bun run api:build

      # A deploy replaces every file under /assets/. Open tabs keep loading the files of their build.
      - name: Keep the code files of earlier builds
        run: bun apps/auth-api/scripts/keep-previous-assets.ts

      - name: Prepare runtime secrets
        id: runtime-secrets
        shell: bash
        env:
          EMAIL_SMTP_PASSWORD: ${{ secrets.EMAIL_SMTP_PASSWORD }}
          SKILLS_ADMIN_TOKEN: ${{ secrets.SKILLS_ADMIN_TOKEN }}
          SITE_OPERATIONS_ADMIN_TOKEN: ${{ secrets.SITE_OPERATIONS_ADMIN_TOKEN }}
          SERVER_CLAIMS_ADMIN_TOKEN: ${{ secrets.SERVER_CLAIMS_ADMIN_TOKEN }}
          SITE_REPORT_HASH_SECRET: ${{ secrets.SITE_REPORT_HASH_SECRET }}
          REMOTE_TICKET_PRIVATE_JWK: ${{ secrets.REMOTE_TICKET_PRIVATE_JWK }}
          REMOTE_TICKET_PUBLIC_JWKS: ${{ secrets.REMOTE_TICKET_PUBLIC_JWKS }}
          REMOTE_AUTH_WEBHOOK_SECRET: ${{ secrets.REMOTE_AUTH_WEBHOOK_SECRET }}
          OPENPANEL_CLIENT_ID: ${{ secrets.OPENPANEL_CLIENT_ID }}
          OPENPANEL_CLIENT_SECRET: ${{ secrets.OPENPANEL_CLIENT_SECRET }}
          # Optional: an unset value keeps the value that the Worker has. See docs/hosted-servers.md.
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
          STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
          BOAT_API_KEY: ${{ secrets.BOAT_API_KEY }}
          BOAT_WEBHOOK_SECRET: ${{ secrets.BOAT_WEBHOOK_SECRET }}
          # GitHub refuses an Actions secret name that starts with GITHUB_. The Worker name is GITHUB_APP_PRIVATE_KEY.
          OPENBOT_GITHUB_APP_PRIVATE_KEY: ${{ secrets.OPENBOT_GITHUB_APP_PRIVATE_KEY }}
          APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }}
          APNS_KEY_ID: ${{ secrets.APNS_KEY_ID }}
          FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }}
          COMPANY_SIGN_IN_SECRET: ${{ secrets.COMPANY_SIGN_IN_SECRET }}
        run: |
          secrets_path="$RUNNER_TEMP/openbot-worker-secrets.json"
          umask 077
          SECRETS_PATH="$secrets_path" bun -e '
            const smtp = process.env.EMAIL_SMTP_PASSWORD;
            const skillsAdmin = process.env.SKILLS_ADMIN_TOKEN;
            const siteOperationsAdmin = process.env.SITE_OPERATIONS_ADMIN_TOKEN;
            const serverClaimsAdmin = process.env.SERVER_CLAIMS_ADMIN_TOKEN;
            const siteReportHash = process.env.SITE_REPORT_HASH_SECRET;
            const remotePrivateJwk = process.env.REMOTE_TICKET_PRIVATE_JWK;
            const remotePublicJwks = process.env.REMOTE_TICKET_PUBLIC_JWKS;
            const remoteWebhookSecret = process.env.REMOTE_AUTH_WEBHOOK_SECRET;
            const openpanelClientId = process.env.OPENPANEL_CLIENT_ID?.trim();
            const openpanelClientSecret = process.env.OPENPANEL_CLIENT_SECRET?.trim();
            if (!openpanelClientId || !openpanelClientSecret) {
              throw new Error("OPENPANEL_CLIENT_ID and OPENPANEL_CLIENT_SECRET are required for production analytics.");
            }
            const path = process.env.SECRETS_PATH;
            if (
              !smtp ||
              !skillsAdmin ||
              !siteReportHash ||
              !remotePrivateJwk ||
              !remotePublicJwks ||
              !remoteWebhookSecret ||
              !path
            ) {
              throw new Error("Required production secrets are missing.");
            }
            // A Stripe or boat key without its webhook secret takes payments or makes sandboxes that
            // the Worker never sees, so each pair is set together or not at all.
            const optional = {};
            for (const names of [
              ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"],
              ["BOAT_API_KEY", "BOAT_WEBHOOK_SECRET"],
              // The Live Activity relay stays off until both are set.
              ["APNS_PRIVATE_KEY", "APNS_KEY_ID"],
            ]) {
              const present = names.filter((name) => process.env[name]?.trim());
              if (present.length === 0) continue;
              if (present.length !== names.length) throw new Error(`Set ${names.join(" and ")} together.`);
              for (const name of names) optional[name] = process.env[name].trim();
            }
            const githubAppKey = process.env.OPENBOT_GITHUB_APP_PRIVATE_KEY?.trim();
            if (githubAppKey) optional.GITHUB_APP_PRIVATE_KEY = githubAppKey;
            // Company sign-in stays off until it is set. It encrypts the stored client secrets, so it never changes.
            const companySignInSecret = process.env.COMPANY_SIGN_IN_SECRET?.trim();
            if (companySignInSecret) {
              if (new TextEncoder().encode(companySignInSecret).byteLength < 32) {
                throw new Error("COMPANY_SIGN_IN_SECRET must have at least 32 bytes.");
              }
              optional.COMPANY_SIGN_IN_SECRET = companySignInSecret;
            }
            // The Android notification relay stays off until the Firebase service account key is set.
            const fcmServiceAccount = process.env.FCM_SERVICE_ACCOUNT?.trim();
            if (fcmServiceAccount) {
              let parsed;
              try {
                parsed = JSON.parse(fcmServiceAccount);
              } catch {
                parsed = null;
              }
              if (parsed?.type !== "service_account") throw new Error("FCM_SERVICE_ACCOUNT is not a Firebase service account key file.");
              optional.FCM_SERVICE_ACCOUNT = fcmServiceAccount;
            }
            const stripeKey = optional.STRIPE_SECRET_KEY;
            if (stripeKey && !stripeKey.startsWith("sk_live_") && !stripeKey.startsWith("rk_live_")) {
              throw new Error("STRIPE_SECRET_KEY is not a live key.");
            }
            const secrets = {
              ...optional,
              OPENPANEL_CLIENT_ID: openpanelClientId,
              OPENPANEL_CLIENT_SECRET: openpanelClientSecret,
              EMAIL_SMTP_PASSWORD: smtp,
              SKILLS_ADMIN_TOKEN: skillsAdmin,
              SITE_REPORT_HASH_SECRET: siteReportHash,
              ...(siteOperationsAdmin ? { SITE_OPERATIONS_ADMIN_TOKEN: siteOperationsAdmin } : {}),
              ...(serverClaimsAdmin ? { SERVER_CLAIMS_ADMIN_TOKEN: serverClaimsAdmin } : {}),
              REMOTE_TICKET_PRIVATE_JWK: remotePrivateJwk,
              REMOTE_TICKET_PUBLIC_JWKS: remotePublicJwks,
              REMOTE_AUTH_WEBHOOK_SECRET: remoteWebhookSecret,
            };
            await Bun.write(path, JSON.stringify(secrets));
          '
          echo "path=$secrets_path" >> "$GITHUB_OUTPUT"

      - name: Apply production D1 migrations
        working-directory: apps/auth-api
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PRODUCTION_DEPLOY_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
        run: ../../node_modules/.bin/wrangler d1 migrations apply DB --remote --config wrangler.jsonc

      - name: Deploy public site router
        working-directory: apps/site-router
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_SITE_ROUTER_DEPLOY_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
        run: ../../node_modules/.bin/wrangler deploy --config wrangler.jsonc --keep-vars

      # The production Worker sends email through this Worker, so it must exist first.
      - name: Deploy email relay Worker
        working-directory: apps/auth-api
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PRODUCTION_DEPLOY_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
        run: ../../node_modules/.bin/wrangler deploy --config wrangler.email-relay.jsonc

      - name: Deploy production Worker
        working-directory: apps/auth-api
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PRODUCTION_DEPLOY_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
        run: ../../node_modules/.bin/wrangler deploy --config dist/server/wrangler.json --keep-vars --secrets-file "${{ steps.runtime-secrets.outputs.path }}"

      - name: Remove temporary runtime secrets
        if: always() && steps.runtime-secrets.outputs.path != ''
        run: rm -f "${{ steps.runtime-secrets.outputs.path }}"

      - name: Verify production deployment
        shell: bash
        env:
          SITE_HOSTING_EXPECTED_ENABLED: ${{ vars.SITE_HOSTING_EXPECTED_ENABLED }}
        # Each check names the URL and the value that it received when it fails. A bare `test` or
        # `grep -q` stops the step with no message, and the logs of earlier failures could not show
        # which check failed. The received headers include `cf-cache-status` and `age`, which show
        # whether the edge served a cached response of the previous Worker version.
        run: |
          verification_dir="$(mktemp -d)"
          trap 'rm -rf "$verification_dir"' EXIT
          fail() {
            echo "::error title=Verify production deployment::$1"
            exit 1
          }
          require_status() {
            test "$1" = "$2" || fail "$3 returned HTTP $1. Expected HTTP $2."
          }
          require_header() {
            if ! tr -d '\r' < "$1" | grep -Eiq "$2"; then
              echo "Response headers of $3:"
              tr -d '\r' < "$1"
              fail "$3 has no response header that matches '$2'."
            fi
          }
          curl --fail --silent --show-error --retry 5 --retry-all-errors https://openbot.run/ >/dev/null
          join_status="$(curl --silent --show-error --retry 5 --retry-all-errors \
            --max-redirs 0 --dump-header "$verification_dir/join.headers" \
            --output "$verification_dir/join.html" --write-out '%{http_code}' \
            https://openbot.run/join)"
          require_status "$join_status" 200 https://openbot.run/join
          require_header "$verification_dir/join.headers" '^cache-control:.*no-store' https://openbot.run/join
          require_header "$verification_dir/join.headers" '^referrer-policy:.*no-referrer' https://openbot.run/join
          association_url=https://openbot.run/.well-known/apple-app-site-association
          association_status="$(curl --silent --show-error --retry 5 --retry-all-errors \
            --max-redirs 0 --dump-header "$verification_dir/association.headers" \
            --output "$verification_dir/association.json" --write-out '%{http_code}' \
            "$association_url")"
          require_status "$association_status" 200 "$association_url"
          require_header "$verification_dir/association.headers" '^content-type: application/json' "$association_url"
          if ! jq -e '.applinks.details == [{"appID":"ZTRDTUL87R.app.openbot.desktop","paths":["/join"]},{"appID":"ZTRDTUL87R.run.openbot.mobile","paths":["/join","/agents/*"]},{"appID":"8A47NKRZSJ.run.openbot.mobile","paths":["/join","/agents/*"]}]' \
            "$verification_dir/association.json" >/dev/null; then
            echo "Response headers of $association_url:"
            tr -d '\r' < "$verification_dir/association.headers"
            echo "Response body of $association_url (first 4000 bytes):"
            head -c 4000 "$verification_dir/association.json"
            echo
            fail "$association_url does not have the expected applinks.details."
          fi
          curl --fail --silent --show-error --retry 5 --retry-all-errors https://api.openbot.run/health/live >/dev/null
          curl --fail --silent --show-error --retry 5 --retry-all-errors https://api.openbot.run/health/ready >/dev/null
          site_url=https://deployment-smoke-check.openbot.site/
          site_status="$(curl --silent --show-error --retry 5 --retry-all-errors \
            --max-redirs 0 --dump-header "$verification_dir/site-router.headers" \
            --output "$verification_dir/site-router.txt" --write-out '%{http_code}' \
            "$site_url")"
          case "$SITE_HOSTING_EXPECTED_ENABLED" in
            true) require_status "$site_status" 404 "$site_url" ;;
            false) require_status "$site_status" 503 "$site_url" ;;
            *) fail "SITE_HOSTING_EXPECTED_ENABLED must be true or false." ;;
          esac
          require_header "$verification_dir/site-router.headers" '^cache-control:.*no-store' "$site_url"
          require_header "$verification_dir/site-router.headers" '^x-content-type-options:.*nosniff' "$site_url"
          require_header "$verification_dir/site-router.headers" '^x-robots-tag:.*noindex' "$site_url"