.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:
  check:
    name: Check
    runs-on: blacksmith-8vcpu-ubuntu-2404
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
          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: voidzero-dev/setup-vp@v1
        with:
          node-version-file: package.json
          cache: true
          run-install: true

      - 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
        run: vp check

      - name: Typecheck
        run: vpr typecheck

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

      - name: Install 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
          sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config

      - name: Build desktop pipeline
        run: vp run build:desktop

      - name: Verify preload bundle output
        run: node apps/desktop/scripts/verify-preload-bundle.mjs

  # Everything except `t3` (apps/server). `--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:
    name: Test
    runs-on: blacksmith-8vcpu-ubuntu-2404
    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: true

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

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

      - name: Install 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
          sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config

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

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

      - name: Test
        run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test

  # apps/server sets `fileParallelism: false`, so its 239 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.
  test_server:
    name: Test Server ${{ matrix.shard }}
    runs-on: blacksmith-8vcpu-ubuntu-2404
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3]
    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: true

      # 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.
      - name: Test
        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 --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }}

      # src/server.test.ts writes the budget report, so 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
    runs-on: blacksmith-4vcpu-ubuntu-2404
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          sparse-checkout: |
            /*
            !/.repos/
          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

  # The static analysis below needs a macOS runner, which bills ~6.7x a Linux
  # minute, so gate it on the native sources it actually lints instead of paying
  # for it on every push. Detection is API-only (no checkout) and fails open: if
  # the diff cannot be resolved, the lint runs.
  mobile_native_changes:
    name: Mobile Native Changes
    runs-on: blacksmith-2vcpu-ubuntu-2404
    timeout-minutes: 5
    permissions:
      contents: read
      pull-requests: read
    outputs:
      changed: ${{ steps.detect.outputs.changed }}
    steps:
      - name: Detect mobile native changes
        id: detect
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          BEFORE_SHA: ${{ github.event.before }}
        run: |
          set -uo pipefail

          fail_open() {
            echo "$* Running native static analysis."
            echo "changed=true" >> "$GITHUB_OUTPUT"
            exit 0
          }

          count_rows() {
            printf '%s\n' "$1" | grep -c . || true
          }

          # One row per changed file, holding the new path and, for a rename,
          # the path it replaced: renaming a matched file out of the matched
          # paths removes a lint input just like editing it.
          row='[.filename, (.previous_filename // empty)] | @tsv'

          if [[ -n "${PR_NUMBER}" ]]; then
            # The PR files endpoint stops at 3000 files and pagination cannot
            # extend it, so cross-check against the count the PR itself reports.
            expected=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.changed_files') \
              || fail_open "Could not read the pull request."
            rows=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq ".[] | ${row}") \
              || fail_open "Could not resolve changed files."

            listed=$(count_rows "$rows")
            if [[ "$listed" -lt "$expected" ]]; then
              fail_open "GitHub listed only ${listed} of ${expected} changed files."
            fi
          else
            rows=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${GITHUB_SHA}" --jq ".files[]? | ${row}") \
              || fail_open "Could not resolve changed files."

            # The compare endpoint reports at most 300 files and pagination does
            # not extend that list, so a full list may be hiding native changes.
            listed=$(count_rows "$rows")
            if [[ "$listed" -ge 300 ]]; then
              fail_open "GitHub listed ${listed} changed files, the compare endpoint maximum."
            fi
          fi

          paths=$(tr '\t' '\n' <<< "$rows")

          # Sources scripts/mobile-native-static-check.ts lints, plus the tool
          # and rule configuration that decides how it lints them, plus the
          # root package.json that defines the lint:mobile command.
          pattern='^apps/mobile/.*\.(swift|kt|kts)$|^apps/mobile/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts/mobile-native-static-check\.ts$|^package\.json$|^\.github/workflows/ci\.yml$'

          if grep -qE "$pattern" <<< "$paths"; then
            echo "Native sources or lint configuration changed:"
            grep -E "$pattern" <<< "$paths"
            echo "changed=true" >> "$GITHUB_OUTPUT"
          else
            echo "No mobile native sources or lint configuration changed."
            echo "changed=false" >> "$GITHUB_OUTPUT"
          fi

  mobile_native_static_analysis:
    name: Mobile Native Static Analysis
    needs: mobile_native_changes
    # Skip only on an explicit "no": a gate job that failed or errored leaves the
    # output empty, and that must run the lint rather than silently skip it.
    if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != '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:
    name: Release Smoke
    runs-on: blacksmith-8vcpu-ubuntu-2404
    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: Exercise release-only workflow steps
        run: node scripts/release-smoke.ts