.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:
  # Lint, typecheck, and the desktop build each get a runner so they run side by
  # side instead of one after another. The `Check` job at the end of this file
  # passes only when every other job does.
  lint:
    name: Lint
    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

  typecheck:
    name: Typecheck
    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

      - name: Typecheck
        run: vpr typecheck

  build:
    name: Build
    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

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

      # Runs in the background while Vite+ installs; the step before the build 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: 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

      - 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")"

      - 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) 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:
    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

      # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present),
      # but ACP process-tree live tests compile a small pthread fixture with `cc`
      # and soft-skip when it is missing. Install build-essential so that path
      # always runs in CI instead of silently no-oping.
      - name: Install C toolchain for process-tree fixtures
        run: |
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends build-essential
          command -v cc

      - 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: 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

      - 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: 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")"

      - name: Test
        run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' 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.
  test_web:
    name: Test Web
    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: Test
        run: vp run --filter @t3tools/web test

  # 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).
  test_server:
    name: Test Server ${{ matrix.shard }}
    runs-on: blacksmith-4vcpu-ubuntu-2404
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4, 5, 6]
    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 }}

      # 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

  transfer-report:
    name: Transfer report artifact
    needs: test_server
    # On Blacksmith like every other job here: the GitHub-hosted queue is shared
    # with the PR bots and can leave this job waiting until it is abandoned,
    # which fails `Check` on an otherwise green run.
    runs-on: blacksmith-2vcpu-ubuntu-2404
    timeout-minutes: 5
    steps:
      - name: Require transfer measurements
        uses: actions/download-artifact@v8
        with:
          name: thread-transfer-results
          path: ${{ runner.temp }}/thread-transfer

  # 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

  # 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:
      [
        lint,
        typecheck,
        build,
        test,
        test_web,
        test_server,
        transfer-report,
        rust,
        mobile_native_changes,
        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)"'
          # Only the macOS lint skips on purpose, when no native mobile code changed.
          echo "$RESULTS" | jq -e 'all(to_entries[];
            .value.result == "success"
            or (.key == "mobile_native_static_analysis" and .value.result == "skipped"))' > /dev/null