.github/workflows/ci.yml

name: CI

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

concurrency:
  group: ci-${{ github.event.pull_request.number || github.sha }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  # Skips jobs a pull request cannot affect: a web change does not run the
  # server shards, a server change does not run the mobile tests, and the macOS
  # lint (~6.7x a Linux minute) runs only for native mobile sources.
  # .github/scripts/ci-changes.cjs maps changed files to workspace packages and
  # their dependents. Pushes to main still run every Linux job. Detection fails
  # open: if the diff cannot be resolved, everything runs.
  changes:
    name: Changes
    runs-on: blacksmith-2vcpu-ubuntu-2404
    timeout-minutes: 5
    outputs:
      typecheck: ${{ steps.detect.outputs.typecheck }}
      build: ${{ steps.detect.outputs.build }}
      test: ${{ steps.detect.outputs.test }}
      test_packages: ${{ steps.detect.outputs.test_packages }}
      test_web: ${{ steps.detect.outputs.test_web }}
      test_server: ${{ steps.detect.outputs.test_server }}
      rust: ${{ steps.detect.outputs.rust }}
      release_smoke: ${{ steps.detect.outputs.release_smoke }}
      mobile_native: ${{ steps.detect.outputs.mobile_native }}
    steps:
      # Only the package manifests the script reads to build the dependency graph.
      # Depth 2 brings the base commit the change is diffed against.
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 2
          sparse-checkout: |
            /pnpm-workspace.yaml
            /.github/scripts/ci-changes.cjs
            /.github/actions/ci-changes/
            /*/package.json
            /*/*/package.json
            !/.repos/
          sparse-checkout-cone-mode: false

      - name: Detect affected jobs
        id: detect
        uses: ./.github/actions/ci-changes

  # Lint and the desktop build each get a runner so they run side by side.
  # Typecheck shares the build's install. Lint always runs: it formats every
  # file, docs included. The `Check` job at the end of this file passes only
  # when every other job passes or was skipped by `changes`.
  lint:
    permissions:
      contents: read
      id-token: write
    name: Lint
    runs-on: blacksmith-8vcpu-ubuntu-2404
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
            !/apps/mobile/modules/t3-terminal/Vendor/**/*.a
          sparse-checkout-cone-mode: false

      - name: Reject repository-owned PR assets
        run: |
          files="$(git ls-files .github/pr-assets)"
          if test -n "$files"; then
            printf 'PR evidence must be uploaded to GitHub, not committed:\n%s\n' "$files" >&2
            exit 1
          fi

      - name: Setup Vite+
        uses: ./.github/actions/setup-ci-deps
        with:
          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}

      # electron's install.js (run by ensure:electron) reads this cache before it
      # downloads from GitHub. The key changes only with the Electron version.
      - name: Cache Electron download
        uses: actions/cache@v6
        with:
          path: ~/.cache/electron
          key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }}

      - name: Ensure Electron runtime is installed
        run: vp run --filter @t3tools/desktop ensure:electron

      # Files/dependencies are repo-wide; export checks cover clean workspaces only.
      - name: Check unused code
        run: vp run knip:check

      - name: Check generated GitHub GraphQL operations
        run: vp run --filter @t3tools/source-control-github generate --check

      - name: Check generated Expo GraphQL operations
        run: vp run --filter t3 generate --check

      - name: Check
        run: vp check

  build:
    permissions:
      contents: read
      id-token: write
    name: Build and Typecheck
    needs: changes
    if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }}
    runs-on: blacksmith-8vcpu-ubuntu-2404
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
            !/apps/mobile/modules/t3-terminal/Vendor/**/*.a
          sparse-checkout-cone-mode: false

      - uses: ./.github/actions/setup-apt-mirrors
        if: needs.changes.outputs.build != 'false'

      # Runs in the background while Vite+ installs; the step before the build waits for it.
      - name: Start installing browser secret helper build libraries
        if: needs.changes.outputs.build != 'false'
        run: |
          sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
          (
            set +e
            sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
            echo "$?" > "$RUNNER_TEMP/apt-status"
          ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &

      - name: Setup Vite+
        uses: ./.github/actions/setup-ci-deps
        with:
          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}

      # electron's install.js (run by ensure:electron) reads this cache before it
      # downloads from GitHub. The key changes only with the Electron version.
      - name: Cache Electron download
        uses: actions/cache@v6
        with:
          path: ~/.cache/electron
          key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }}

      - name: Ensure Electron runtime is installed
        id: electron
        run: vp run --filter @t3tools/desktop ensure:electron

      # Runs in the background while the build runs, and reports in the last
      # step: neither result hides the other.
      - name: Start typecheck
        id: typecheck
        if: ${{ !cancelled() && steps.electron.outcome == 'success' && needs.changes.outputs.typecheck != 'false' }}
        run: |
          (
            set +e
            vpr typecheck
            echo "$?" > "$RUNNER_TEMP/typecheck-status"
          ) > "$RUNNER_TEMP/typecheck.log" 2>&1 < /dev/null &

      - name: Finish installing browser secret helper build libraries
        id: apt
        if: ${{ !cancelled() && needs.changes.outputs.build != 'false' }}
        timeout-minutes: 5
        run: |
          until test -s "$RUNNER_TEMP/apt-status"; do sleep 1; done
          cat "$RUNNER_TEMP/apt.log"
          exit "$(cat "$RUNNER_TEMP/apt-status")"

      - name: Build desktop pipeline
        id: build
        if: ${{ !cancelled() && steps.electron.outcome == 'success' && steps.apt.outcome == 'success' && needs.changes.outputs.build != 'false' }}
        run: vp run build:desktop

      - name: Verify preload bundle output
        if: ${{ !cancelled() && steps.build.outcome == 'success' }}
        run: node apps/desktop/scripts/verify-preload-bundle.mjs

      - name: Typecheck
        if: ${{ !cancelled() && steps.typecheck.outcome == 'success' }}
        timeout-minutes: 5
        run: |
          until test -s "$RUNNER_TEMP/typecheck-status"; do sleep 1; done
          cat "$RUNNER_TEMP/typecheck.log"
          exit "$(cat "$RUNNER_TEMP/typecheck-status")"

  # Everything except `t3` (apps/server) and the web app, which has its own job
  # below. `--parallel` drops the package dependency ordering that `vp run`
  # applies by default: these `test` tasks declare no `dependsOn` and resolve
  # workspace deps from source, so ordering only bought us idle runners between
  # dependency layers. The concurrency limit stays at the default 4 so peak load
  # per runner is unchanged.
  test:
    permissions:
      contents: read
      id-token: write
    name: Test
    needs: changes
    if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }}
    runs-on: blacksmith-8vcpu-ubuntu-2404
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
            !/apps/mobile/modules/t3-terminal/Vendor/**/*.a
          sparse-checkout-cone-mode: false

      - uses: ./.github/actions/setup-apt-mirrors

      # The desktop tests compile the browser secret helper. The install runs in
      # the background while Vite+ installs; the step before the tests waits for it.
      - name: Start installing browser secret helper build libraries
        run: |
          sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
          (
            set +e
            sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
            echo "$?" > "$RUNNER_TEMP/apt-status"
          ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &

      - name: Setup Vite+
        uses: ./.github/actions/setup-ci-deps
        with:
          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}

      # electron's install.js (run by ensure:electron) reads this cache before it
      # downloads from GitHub. The key changes only with the Electron version.
      - name: Cache Electron download
        uses: actions/cache@v6
        with:
          path: ~/.cache/electron
          key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }}

      - name: Ensure Electron runtime is installed
        run: vp run --filter @t3tools/desktop ensure:electron

      - name: Test preview artifact validation
        run: python3 -B .github/scripts/stage-preview-bundle.test.py

      - name: Test release checks
        run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs

      - name: Finish installing browser secret helper build libraries
        timeout-minutes: 5
        run: |
          until test -s "$RUNNER_TEMP/apt-status"; do sleep 1; done
          cat "$RUNNER_TEMP/apt.log"
          exit "$(cat "$RUNNER_TEMP/apt-status")"

      # `changes` lists the affected packages, or nothing when all of them are.
      - name: Test
        env:
          TEST_PACKAGES: ${{ needs.changes.outputs.test_packages }}
        run: |
          filters=(--filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web')
          if [[ -n "$TEST_PACKAGES" ]]; then
            filters=()
            for package in $TEST_PACKAGES; do filters+=(--filter "$package"); done
          fi
          vp run --parallel --concurrency-limit 4 "${filters[@]}" test

  # The web suite is the largest outside the server. In the shared Test job it
  # started last and ran alone for about 40s after everything else finished.
  # release.yml mirrors this test, test_web, and test_server split.
  test_web:
    permissions:
      contents: read
      id-token: write
    name: Test Web
    needs: changes
    if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }}
    runs-on: blacksmith-8vcpu-ubuntu-2404
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
            !/apps/mobile/modules/t3-terminal/Vendor/**/*.a
          sparse-checkout-cone-mode: false

      - name: Setup Vite+
        uses: ./.github/actions/setup-ci-deps
        with:
          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}

      # The browser project runs React tests in headless Chromium. Same cache key as
      # test_server, so both jobs share one downloaded browser.
      - name: Resolve Playwright version
        id: playwright_version
        run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT"

      - name: Cache Chromium
        id: chromium_cache
        uses: actions/cache@v6
        with:
          path: ~/.cache/ms-playwright
          key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }}

      - name: Install Chromium for web browser tests
        if: steps.chromium_cache.outputs.cache-hit != 'true'
        run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium

      - name: Install missing Chromium system dependencies
        run: |
          browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit)
          test -n "$browser"
          if ldd "$browser" | grep '=> not found'; then
            node apps/web/node_modules/playwright/cli.js install-deps chromium
          fi

      - name: Test
        run: vp run --filter @t3tools/web test

      # Browser tests run source modules; this boots the production chunk graph.
      - name: Boot the built web client
        run: |
          vp run --filter @t3tools/web build
          node apps/web/scripts/verify-built-boot.ts

  # apps/server sets `fileParallelism: false`, so its test files run strictly
  # one at a time. Sharding spreads them over separate runners instead of
  # separate workers, so no two server test files ever share a machine and the
  # isolation that flag buys is preserved exactly. One file at a time keeps
  # about 1.5 cores busy, so 4 vCPU runners lose nothing against 8. The shards
  # split files by recorded duration (apps/server/src/testUtils/weightedShardSequencer.ts).
  # The shards are the longest jobs, so they gate themselves instead of waiting
  # ~15s for `changes`: a shard the change cannot affect stops after checkout.
  test_server:
    permissions:
      contents: read
      id-token: write
    name: Test Server ${{ matrix.shard }}
    runs-on: blacksmith-4vcpu-ubuntu-2404
    # Shards take 2-5 minutes. When the shared task cache throttles reads, each
    # failed fetch stalls vp for 15-90s, which pushed shards past 10 minutes.
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8]
    steps:
      # Depth 2 brings the base commit the change is diffed against.
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 2
          sparse-checkout: |
            /*
            !/.repos/
            !/apps/mobile/modules/t3-terminal/Vendor/**/*.a
          sparse-checkout-cone-mode: false

      - name: Detect affected jobs
        id: changes
        uses: ./.github/actions/ci-changes

      - name: Setup Vite+
        if: steps.changes.outputs.test_server != 'false'
        uses: ./.github/actions/setup-ci-deps
        with:
          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}

      # No Electron setup here: `t3` (apps/server) has no Electron dependency
      # and none of its tests touch the runtime. Only the non-server `test`
      # job, which covers @t3tools/desktop, needs the download.
      # Cache the pinned browser and skip apt unless shared libraries are missing.
      - name: Resolve Playwright version
        id: playwright_version
        if: steps.changes.outputs.test_server != 'false'
        run: echo "version=$(node -p "require('./apps/server/node_modules/playwright-core/package.json').version")" >> "$GITHUB_OUTPUT"

      - name: Cache Chromium
        id: chromium_cache
        if: steps.changes.outputs.test_server != 'false'
        uses: actions/cache@v6
        with:
          path: ~/.cache/ms-playwright
          key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }}

      - name: Install Chromium for server browser integration tests
        if: steps.changes.outputs.test_server != 'false' && steps.chromium_cache.outputs.cache-hit != 'true'
        run: node apps/server/node_modules/playwright-core/cli.js install --only-shell chromium

      - name: Install missing Chromium system dependencies
        if: steps.changes.outputs.test_server != 'false'
        run: |
          browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit)
          test -n "$browser"
          if ldd "$browser" | grep '=> not found'; then
            node apps/server/node_modules/playwright-core/cli.js install-deps chromium
          fi

      - name: Test
        if: steps.changes.outputs.test_server != 'false'
        env:
          T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md
          T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json
        run: >-
          vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }}
          --uncached apps/server/integration/transferBudgetV2.integration.test.ts
          --verify-hits 1 --report "${{ runner.temp }}/test-cache-${{ matrix.shard }}.json" apps/server

      - name: Upload test cache measurements
        if: always() && steps.changes.outputs.test_server != 'false'
        uses: actions/upload-artifact@v7
        with:
          name: test-cache-${{ matrix.shard }}
          path: ${{ runner.temp }}/test-cache-${{ matrix.shard }}.json
          if-no-files-found: error

      # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard
      # produces these files. Gating the upload on their presence keeps a
      # single `thread-transfer-results` artifact per run, which is the name
      # thread-transfer-report.yml resolves.
      - name: Detect transfer budget report
        id: transfer_budget
        if: always()
        run: |
          if test -f "${{ runner.temp }}/thread-transfer-result.json"; then
            echo "present=true" >> "$GITHUB_OUTPUT"
          else
            echo "present=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Publish transfer budget report
        if: always() && steps.transfer_budget.outputs.present == 'true'
        run: |
          if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then
            tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md"
          else
            echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY"
          fi

      - name: Upload thread transfer result
        if: always() && steps.transfer_budget.outputs.present == 'true'
        uses: actions/upload-artifact@v7
        with:
          name: thread-transfer-results
          path: ${{ runner.temp }}/thread-transfer-result.json
          if-no-files-found: ignore
          retention-days: 30

  # Split out of Check and Test: both paid ~7-9s to install a Rust toolchain
  # for checks that take under 3s, on the critical path of every PR.
  rust:
    name: Rust
    needs: changes
    if: ${{ !cancelled() && needs.changes.outputs.rust != 'false' }}
    runs-on: blacksmith-4vcpu-ubuntu-2404
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
            !/apps/mobile/modules/t3-terminal/Vendor/**/*.a
          sparse-checkout-cone-mode: false

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt

      - name: Check Rust formatting
        run: |
          for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do
            cargo fmt --manifest-path "native/$crate/Cargo.toml" -- --check
          done

      - name: Test Rust crates
        run: |
          for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do
            cargo test --locked --manifest-path "native/$crate/Cargo.toml"
          done

  mobile_native_static_analysis:
    name: Mobile Native Static Analysis
    needs: changes
    if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }}
    runs-on: blacksmith-6vcpu-macos-26
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
          sparse-checkout-cone-mode: false

      - name: Setup Vite+
        uses: voidzero-dev/setup-vp@v1
        with:
          node-version-file: package.json
          cache: true
          run-install: |
            args:
              - --filter=@t3tools/scripts...

      - name: Install mobile native static analysis tools
        run: brew bundle install --file apps/mobile/Brewfile

      - name: Lint mobile native sources
        run: vp run lint:mobile

  release_smoke:
    permissions:
      contents: read
      id-token: write
    name: Release Smoke
    needs: changes
    if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }}
    runs-on: blacksmith-8vcpu-ubuntu-2404
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
            !/apps/mobile/modules/t3-terminal/Vendor/**/*.a
          sparse-checkout-cone-mode: false

      - name: Setup Vite+
        uses: ./.github/actions/setup-ci-deps
        with:
          task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }}

      - name: Exercise release-only workflow steps
        run: node scripts/release-smoke.ts

  # Branch protection requires this job by its name, `Check`. It fails when any
  # other job fails, is cancelled, or is skipped without a reason, so splitting
  # or resharding jobs needs no ruleset change. Add every new job to `needs`.
  # `always()` rather than `!cancelled()`: a skipped job reports success to
  # branch protection, so a cancelled run must still produce a failing check.
  check:
    name: Check
    if: ${{ always() }}
    needs:
      [
        changes,
        lint,
        build,
        test,
        test_web,
        test_server,
        rust,
        mobile_native_static_analysis,
        release_smoke,
      ]
    runs-on: blacksmith-2vcpu-ubuntu-2404
    timeout-minutes: 5
    steps:
      - name: Require every job to pass
        env:
          RESULTS: ${{ toJSON(needs) }}
        run: |
          echo "$RESULTS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
          # A job may skip only when `changes` said every gate it represents was
          # not needed. Gates are keyed by job name, except merged Build and the
          # macOS lint.
          echo "$RESULTS" | jq -e '.changes.outputs as $gates
            | {build: ["build", "typecheck"], mobile_native_static_analysis: ["mobile_native"]} as $renamed
            | all(to_entries[];
                .value.result == "success"
                or (.value.result == "skipped"
                  and (($renamed[.key] // [.key]) | all(.[]; $gates[.] == "false"))))' > /dev/null

      # thread-transfer-report.yml reads this artifact after CI, so a run whose
      # server tests ran must have produced it.
      - name: Require transfer measurements
        if: needs.test_server.result == 'success' && needs.changes.outputs.test_server != 'false'
        uses: actions/download-artifact@v8
        with:
          name: thread-transfer-results
          path: ${{ runner.temp }}/thread-transfer