name: CI

on:
  push:
    branches: [main]
  pull_request:
    types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]

concurrency:
  group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-push-{0}', github.run_id) }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  # Preflight: establish the fast global truth for this revision before the
  # expensive platform and test lanes fan out.
  # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
  # Run scope detection, changed-extension detection, and fast security checks in
  # one visible job so operators have a single preflight box to inspect and rerun.
  # Fail-safe: if detection steps are skipped, downstream outputs fall back to
  # conservative defaults that keep heavy lanes enabled.
  preflight:
    if: github.event_name != 'pull_request' || !github.event.pull_request.draft
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    outputs:
      docs_only: ${{ steps.docs_scope.outputs.docs_only }}
      docs_changed: ${{ steps.docs_scope.outputs.docs_changed }}
      run_node: ${{ steps.changed_scope.outputs.run_node || 'false' }}
      run_macos: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
      run_android: ${{ steps.changed_scope.outputs.run_android || 'false' }}
      run_skills_python: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
      run_windows: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
      has_changed_extensions: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
      changed_extensions_matrix: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 1
          fetch-tags: false
          submodules: false

      - name: Ensure preflight base commit
        uses: ./.github/actions/ensure-base-commit
        with:
          base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
          fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}

      - name: Detect docs-only changes
        id: docs_scope
        uses: ./.github/actions/detect-docs-changes

      # Detect which heavy areas are touched so CI can skip unrelated expensive jobs.
      # Fail-safe: if skipped, downstream lanes run.
      - name: Detect changed scopes
        id: changed_scope
        if: steps.docs_scope.outputs.docs_only != 'true'
        shell: bash
        run: |
          set -euo pipefail

          if [ "${{ github.event_name }}" = "push" ]; then
            BASE="${{ github.event.before }}"
          else
            BASE="${{ github.event.pull_request.base.sha }}"
          fi

          node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD

      - name: Setup Node environment
        if: steps.docs_scope.outputs.docs_only != 'true'
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          install-deps: "false"
          use-sticky-disk: "false"

      - name: Detect changed extensions
        id: changed_extensions
        if: steps.docs_scope.outputs.docs_only != 'true' && steps.changed_scope.outputs.run_node == 'true'
        env:
          BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
          BASE_REF: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
        run: |
          node --input-type=module <<'EOF'
          import { appendFileSync } from "node:fs";
          import { listChangedExtensionIds } from "./scripts/test-extension.mjs";

          const extensionIds = listChangedExtensionIds({
            base: process.env.BASE_SHA,
            head: "HEAD",
            fallbackBaseRef: process.env.BASE_REF,
            unavailableBaseBehavior: "all",
          });
          const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });

          appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
          appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
          EOF

      - name: Setup Python
        id: setup-python
        uses: actions/setup-python@v6
        with:
          python-version: "3.12"
          cache: "pip"
          cache-dependency-path: |
            pyproject.toml
            .pre-commit-config.yaml
            .github/workflows/ci.yml

      - name: Restore pre-commit cache
        uses: actions/cache@v5
        with:
          path: ~/.cache/pre-commit
          key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}

      - name: Install pre-commit
        run: |
          python -m pip install --upgrade pip
          python -m pip install pre-commit

      - name: Detect committed private keys
        run: pre-commit run --all-files detect-private-key

      - name: Audit changed GitHub workflows with zizmor
        env:
          BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail

          if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then
            echo "No usable base SHA detected; skipping zizmor."
            exit 0
          fi

          if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
            echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor."
            exit 0
          fi

          mapfile -t workflow_files < <(
            git diff --name-only "${BASE_SHA}" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml'
          )
          if [ "${#workflow_files[@]}" -eq 0 ]; then
            echo "No workflow changes detected; skipping zizmor."
            exit 0
          fi

          printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
          pre-commit run zizmor --files "${workflow_files[@]}"

      - name: Audit production dependencies
        if: steps.docs_scope.outputs.docs_only != 'true'
        run: pre-commit run --all-files pnpm-audit-prod

  # Fanout: downstream lanes branch from preflight outputs instead of waiting
  # on unrelated Linux checks.
  # Build dist once for Node-relevant changes and share it with downstream jobs.
  build-artifacts:
    needs: [preflight]
    if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Ensure secrets base commit (PR fast path)
        if: github.event_name == 'pull_request'
        uses: ./.github/actions/ensure-base-commit
        with:
          base-sha: ${{ github.event.pull_request.base.sha }}
          fetch-ref: ${{ github.event.pull_request.base.ref }}

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Build dist
        run: pnpm build

      - name: Upload dist artifact
        uses: actions/upload-artifact@v7
        with:
          name: dist-build
          path: dist/
          retention-days: 1

  # Validate npm pack contents after build (only on push to main, not PRs).
  release-check:
    needs: [preflight, build-artifacts]
    if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Download dist artifact
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Check release contents
        run: pnpm release:check

  checks:
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        include:
          - runtime: node
            task: test
            shard_index: 1
            shard_count: 2
            command: pnpm canvas:a2ui:bundle && pnpm test
          - runtime: node
            task: test
            shard_index: 2
            shard_count: 2
            command: pnpm canvas:a2ui:bundle && pnpm test
          - runtime: node
            task: extensions
            command: pnpm test:extensions
          - runtime: node
            task: channels
            command: pnpm test:channels
          - runtime: node
            task: contracts
            command: pnpm test:contracts
          - runtime: node
            task: protocol
            command: pnpm protocol:check
          - runtime: bun
            task: test
            command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
          - runtime: node
            task: compat-node22
            node_version: "22.x"
            cache_key_suffix: "node22"
            command: |
              pnpm build
              node openclaw.mjs --help
              node openclaw.mjs status --json --timeout 1
              pnpm test:build:singleton
              node scripts/stage-bundled-plugin-runtime-deps.mjs
              node --import tsx scripts/release-check.ts
    steps:
      - name: Skip compatibility lanes on pull requests
        if: github.event_name == 'pull_request' && (matrix.runtime == 'bun' || matrix.task == 'compat-node22')
        run: echo "Skipping push-only lane on pull requests."

      - name: Checkout
        if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
        uses: ./.github/actions/setup-node-env
        with:
          node-version: "${{ matrix.node_version || '24.x' }}"
          cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
          install-bun: "${{ matrix.runtime == 'bun' }}"
          use-sticky-disk: "false"

      - name: Configure Node test resources
        if: (github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')) && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'compat-node22')
        env:
          SHARD_COUNT: ${{ matrix.shard_count || '' }}
          SHARD_INDEX: ${{ matrix.shard_index || '' }}
        run: |
          # `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
          # Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
          echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
          echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
          if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then
            echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
            echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
          fi

      - name: Download dist artifact
        if: github.event_name == 'push' && matrix.task == 'test'
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Build dist
        if: github.event_name != 'push' && matrix.task == 'test' && matrix.runtime == 'node'
        run: pnpm build

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
        run: ${{ matrix.command }}

  extension-fast:
    name: "extension-fast"
    needs: [preflight]
    if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.preflight.outputs.has_changed_extensions == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.changed_extensions_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Run changed extension tests
        env:
          OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }}
        run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION"

  # Types, lint, and format check.
  check:
    name: "check"
    needs: [preflight]
    if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Check types and lint and oxfmt
        run: pnpm check

      - name: Strict TS build smoke
        run: pnpm build:strict-smoke

  check-additional:
    name: "check-additional"
    needs: [preflight]
    if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Run plugin extension boundary guard
        id: plugin_extension_boundary
        continue-on-error: true
        run: pnpm run lint:plugins:no-extension-imports

      - name: Run web search provider boundary guard
        id: web_search_provider_boundary
        continue-on-error: true
        run: pnpm run lint:web-search-provider-boundaries

      - name: Run extension src boundary guard
        id: extension_src_outside_plugin_sdk_boundary
        continue-on-error: true
        run: pnpm run lint:extensions:no-src-outside-plugin-sdk

      - name: Run extension plugin-sdk-internal guard
        id: extension_plugin_sdk_internal_boundary
        continue-on-error: true
        run: pnpm run lint:extensions:no-plugin-sdk-internal

      - name: Enforce safe external URL opening policy
        id: no_raw_window_open
        continue-on-error: true
        run: pnpm lint:ui:no-raw-window-open

      - name: Run gateway watch regression harness
        id: gateway_watch_regression
        continue-on-error: true
        run: pnpm test:gateway:watch-regression

      - name: Check config docs drift statefile
        id: config_docs_drift
        continue-on-error: true
        run: pnpm config:docs:check

      - name: Check plugin SDK API baseline drift
        id: plugin_sdk_api_drift
        continue-on-error: true
        run: pnpm plugin-sdk:api:check

      - name: Upload gateway watch regression artifacts
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: gateway-watch-regression
          path: .local/gateway-watch-regression/
          retention-days: 7

      - name: Fail if any additional check failed
        if: always()
        env:
          PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
          WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
          EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
          EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
          NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
          GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
          CONFIG_DOCS_DRIFT_OUTCOME: ${{ steps.config_docs_drift.outcome }}
          PLUGIN_SDK_API_DRIFT_OUTCOME: ${{ steps.plugin_sdk_api_drift.outcome }}
        run: |
          failures=0
          for result in \
            "plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
            "web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
            "extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
            "extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
            "lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
            "gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME" \
            "config-docs-drift|$CONFIG_DOCS_DRIFT_OUTCOME" \
            "plugin-sdk-api-drift|$PLUGIN_SDK_API_DRIFT_OUTCOME"; do
            name="${result%%|*}"
            outcome="${result#*|}"
            if [ "$outcome" != "success" ]; then
              echo "::error title=${name} failed::${name} outcome: ${outcome}"
              failures=1
            fi
          done

          exit "$failures"

  build-smoke:
    name: "build-smoke"
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Download dist artifact
        if: github.event_name == 'push'
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Build dist
        if: github.event_name != 'push'
        run: pnpm build

      - name: Smoke test CLI launcher help
        run: node openclaw.mjs --help

      - name: Smoke test CLI launcher status json
        run: node openclaw.mjs status --json --timeout 1

      - name: Smoke test built bundled plugin singleton
        run: pnpm test:build:singleton

      - name: Check CLI startup memory
        run: pnpm test:startup:memory

  # Validate docs (format, lint, broken links) only when docs files changed.
  check-docs:
    needs: [preflight]
    if: needs.preflight.outputs.docs_changed == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Check docs
        run: pnpm check:docs

  skills-python:
    needs: [preflight]
    if: needs.preflight.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.preflight.outputs.run_skills_python == 'true')
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.12"

      - name: Install Python tooling
        run: |
          python -m pip install --upgrade pip
          python -m pip install pytest ruff pyyaml

      - name: Lint Python skill scripts
        run: python -m ruff check skills

      - name: Test skill Python scripts
        run: python -m pytest -q skills

  checks-windows:
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
    runs-on: blacksmith-32vcpu-windows-2025
    timeout-minutes: 20
    env:
      NODE_OPTIONS: --max-old-space-size=6144
      # Keep total concurrency predictable on the 32 vCPU runner.
      # Windows shard 2 has shown intermittent instability at 2 workers.
      OPENCLAW_TEST_WORKERS: 1
    defaults:
      run:
        shell: bash
    strategy:
      fail-fast: false
      matrix:
        include:
          - runtime: node
            task: test
            shard_index: 1
            shard_count: 6
            command: pnpm test
          - runtime: node
            task: test
            shard_index: 2
            shard_count: 6
            command: pnpm test
          - runtime: node
            task: test
            shard_index: 3
            shard_count: 6
            command: pnpm test
          - runtime: node
            task: test
            shard_index: 4
            shard_count: 6
            command: pnpm test
          - runtime: node
            task: test
            shard_index: 5
            shard_count: 6
            command: pnpm test
          - runtime: node
            task: test
            shard_index: 6
            shard_count: 6
            command: pnpm test
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Try to exclude workspace from Windows Defender (best-effort)
        shell: pwsh
        run: |
          $cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
          if (-not $cmd) {
            Write-Host "Add-MpPreference not available, skipping Defender exclusions."
            exit 0
          }

          try {
            # Defender sometimes intercepts process spawning (vitest workers). If this fails
            # (eg hardened images), keep going and rely on worker limiting above.
            Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
            Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
            Write-Host "Defender exclusions applied."
          } catch {
            Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
          }

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24.x
          check-latest: false

      - name: Setup pnpm + cache store
        uses: ./.github/actions/setup-pnpm-store-cache
        with:
          pnpm-version: "10.23.0"
          cache-key-suffix: "node24"
          # Sticky disk mount currently retries/fails on every shard and adds ~50s
          # before install while still yielding zero pnpm store reuse.
          # Try exact-key actions/cache restores instead to recover store reuse
          # without the sticky-disk mount penalty.
          use-sticky-disk: "false"
          use-restore-keys: "false"
          use-actions-cache: "true"

      - name: Runtime versions
        run: |
          node -v
          npm -v
          pnpm -v

      - name: Capture node path
        run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"

      - name: Install dependencies
        env:
          CI: true
        run: |
          export PATH="$NODE_BIN:$PATH"
          which node
          node -v
          pnpm -v
          # Persist Windows-native postinstall outputs in the pnpm store so restored
          # caches can skip repeated rebuild/download work on later shards/runs.
          pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true

      - name: Configure test shard (Windows)
        if: matrix.task == 'test'
        run: |
          echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
          echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"

      - name: Build A2UI bundle (Windows)
        if: matrix.task == 'test'
        run: pnpm canvas:a2ui:bundle

      - name: Download dist artifact
        if: github.event_name == 'push' && matrix.task == 'test'
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Build dist (Windows)
        if: github.event_name != 'push' && matrix.task == 'test'
        run: pnpm build

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        run: ${{ matrix.command }}

  # Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
  # on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
  # running 4 separate jobs per PR (as before) starved the queue. One job
  # per PR allows 5 PRs to run macOS checks simultaneously.
  macos:
    needs: [preflight]
    if: github.event_name == 'pull_request' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_macos == 'true'
    runs-on: macos-latest
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"

      - name: Build dist (macOS)
        run: pnpm build

      # --- Run all checks sequentially (fast gates first) ---
      - name: TS tests (macOS)
        env:
          NODE_OPTIONS: --max-old-space-size=4096
        run: pnpm test

      # --- Xcode/Swift setup ---
      - name: Select Xcode 26.1
        run: |
          sudo xcode-select -s /Applications/Xcode_26.1.app
          xcodebuild -version

      - name: Install XcodeGen / SwiftLint / SwiftFormat
        run: brew install xcodegen swiftlint swiftformat

      - name: Show toolchain
        run: |
          sw_vers
          xcodebuild -version
          swift --version

      - name: Swift lint
        run: |
          swiftlint --config .swiftlint.yml
          swiftformat --lint apps/macos/Sources --config .swiftformat

      - name: Cache SwiftPM
        uses: actions/cache@v5
        with:
          path: ~/Library/Caches/org.swift.swiftpm
          key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
          restore-keys: |
            ${{ runner.os }}-swiftpm-

      - name: Swift build (release)
        run: |
          set -euo pipefail
          for attempt in 1 2 3; do
            if swift build --package-path apps/macos --configuration release; then
              exit 0
            fi
            echo "swift build failed (attempt $attempt/3). Retrying…"
            sleep $((attempt * 20))
          done
          exit 1

      - name: Swift test
        run: |
          set -euo pipefail
          for attempt in 1 2 3; do
            if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
              exit 0
            fi
            echo "swift test failed (attempt $attempt/3). Retrying…"
            sleep $((attempt * 20))
          done
          exit 1

  android:
    needs: [preflight]
    if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_android == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        include:
          - task: test-play
            command: ./gradlew --no-daemon :app:testPlayDebugUnitTest
          - task: test-third-party
            command: ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
          - task: build-play
            command: ./gradlew --no-daemon :app:assemblePlayDebug
          - task: build-third-party
            command: ./gradlew --no-daemon :app:assembleThirdPartyDebug
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Java
        uses: actions/setup-java@v5
        with:
          distribution: temurin
          # Keep sdkmanager on the stable JDK path for Linux CI runners.
          java-version: 17

      - name: Setup Android SDK cmdline-tools
        run: |
          set -euo pipefail
          ANDROID_SDK_ROOT="$HOME/.android-sdk"
          CMDLINE_TOOLS_VERSION="12266719"
          ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
          URL="https://dl.google.com/android/repository/${ARCHIVE}"

          mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
          curl -fsSL "$URL" -o "/tmp/${ARCHIVE}"
          rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
          unzip -q "/tmp/${ARCHIVE}" -d "$ANDROID_SDK_ROOT/cmdline-tools"
          mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"

          echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
          echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
          echo "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
          echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v5
        with:
          gradle-version: 8.11.1

      - name: Install Android SDK packages
        run: |
          yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
          sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
            "platform-tools" \
            "platforms;android-36" \
            "build-tools;36.0.0"

      - name: Run Android ${{ matrix.task }}
        working-directory: apps/android
        run: ${{ matrix.command }}
