name: CI

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

permissions:
  contents: read

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

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  # Preflight: establish routing truth and job matrices once, then let real
  # work fan out from a single source of truth.
  preflight:
    permissions:
      contents: read
    if: github.event_name != 'pull_request' || !github.event.pull_request.draft
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    outputs:
      docs_only: ${{ steps.manifest.outputs.docs_only }}
      docs_changed: ${{ steps.manifest.outputs.docs_changed }}
      run_node: ${{ steps.manifest.outputs.run_node }}
      run_macos: ${{ steps.manifest.outputs.run_macos }}
      run_android: ${{ steps.manifest.outputs.run_android }}
      run_skills_python: ${{ steps.manifest.outputs.run_skills_python }}
      run_skills_python_job: ${{ steps.manifest.outputs.run_skills_python_job }}
      run_windows: ${{ steps.manifest.outputs.run_windows }}
      has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
      changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
      run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
      run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
      checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
      checks_node_extensions_matrix: ${{ steps.manifest.outputs.checks_node_extensions_matrix }}
      run_checks: ${{ steps.manifest.outputs.run_checks }}
      checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
      checks_node_core_test_matrix: ${{ steps.manifest.outputs.checks_node_core_test_matrix }}
      run_extension_fast: ${{ steps.manifest.outputs.run_extension_fast }}
      extension_fast_matrix: ${{ steps.manifest.outputs.extension_fast_matrix }}
      run_check: ${{ steps.manifest.outputs.run_check }}
      run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
      run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
      run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
      run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
      run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
      checks_windows_matrix: ${{ steps.manifest.outputs.checks_windows_matrix }}
      run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
      macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
      run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
      run_android_job: ${{ steps.manifest.outputs.run_android_job }}
      android_matrix: ${{ steps.manifest.outputs.android_matrix }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 1
          fetch-tags: false
          persist-credentials: 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

      - 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/lib/changed-extensions.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: Build CI manifest
        id: manifest
        env:
          OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
          OPENCLAW_CI_DOCS_CHANGED: ${{ steps.docs_scope.outputs.docs_changed }}
          OPENCLAW_CI_RUN_NODE: ${{ steps.changed_scope.outputs.run_node || 'false' }}
          OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
          OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }}
          OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
          OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
          OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
          OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
          OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
        run: |
          node --input-type=module <<'EOF'
          import { appendFileSync } from "node:fs";
          import {
            createNodeTestShards,
          } from "./scripts/lib/ci-node-test-plan.mjs";
          import {
            createExtensionTestShards,
            DEFAULT_EXTENSION_TEST_SHARD_COUNT,
          } from "./scripts/lib/extension-test-plan.mjs";

          const parseBoolean = (value, fallback = false) => {
            if (value === undefined) return fallback;
            const normalized = value.trim().toLowerCase();
            if (normalized === "true" || normalized === "1") return true;
            if (normalized === "false" || normalized === "0" || normalized === "") return false;
            return fallback;
          };

          const parseJson = (value, fallback) => {
            try {
              return value ? JSON.parse(value) : fallback;
            } catch {
              return fallback;
            }
          };

          const createMatrix = (include) => ({ include });
          const outputPath = process.env.GITHUB_OUTPUT;
          const eventName = process.env.GITHUB_EVENT_NAME ?? "pull_request";
          const isPush = eventName === "push";
          const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
          const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
          const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
          const runMacos = parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly;
          const runAndroid = parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly;
          const runWindows = parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly;
          const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
          const runControlUiI18n =
            parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly;
          const hasChangedExtensions =
            parseBoolean(process.env.OPENCLAW_CI_HAS_CHANGED_EXTENSIONS) && !docsOnly;
          const changedExtensionsMatrix = hasChangedExtensions
            ? parseJson(process.env.OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX, { include: [] })
            : { include: [] };
          const extensionShardMatrix = createMatrix(
            runNode
              ? createExtensionTestShards({
                  shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT,
                }).map((shard) => ({
                  check_name: shard.checkName,
                  extensions_csv: shard.extensionIds.join(","),
                  shard_index: shard.index + 1,
                  task: "extensions-batch",
                }))
              : [],
          );

          const manifest = {
            docs_only: docsOnly,
            docs_changed: docsChanged,
            run_node: runNode,
            run_macos: runMacos,
            run_android: runAndroid,
            run_skills_python: runSkillsPython,
            run_windows: runWindows,
            has_changed_extensions: hasChangedExtensions,
            changed_extensions_matrix: changedExtensionsMatrix,
            run_build_artifacts: runNode,
            run_checks_fast: runNode,
            checks_fast_core_matrix: createMatrix(
              runNode
                ? [
                    { check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
                    {
                      check_name: "checks-fast-contracts-protocol",
                      runtime: "node",
                      task: "contracts-protocol",
                    },
                  ]
                : [],
            ),
            checks_node_extensions_matrix: extensionShardMatrix,
            run_checks: runNode,
            checks_matrix: createMatrix(
              runNode
                ? [
                    { check_name: "checks-node-channels", runtime: "node", task: "channels" },
                    ...(isPush
                      ? [
                          {
                            check_name: "checks-node-compat-node22",
                            runtime: "node",
                            task: "compat-node22",
                            node_version: "22.18.0",
                            cache_key_suffix: "node22",
                          },
                        ]
                      : []),
                  ]
                : [],
            ),
            checks_node_core_test_matrix: createMatrix(
              runNode
                ? createNodeTestShards().map((shard) => ({
                    check_name: shard.checkName,
                    runtime: "node",
                    task: "test-shard",
                    shard_name: shard.shardName,
                    configs: shard.configs,
                    requires_dist: shard.requiresDist,
                  }))
                : [],
            ),
            run_extension_fast: hasChangedExtensions,
            extension_fast_matrix: createMatrix(
              hasChangedExtensions
                ? (changedExtensionsMatrix.include ?? []).map((entry) => ({
                    check_name: `extension-fast-${entry.extension}`,
                    extension: entry.extension,
                  }))
                : [],
            ),
            run_check: runNode,
            run_check_additional: runNode,
            run_build_smoke: runNode,
            run_check_docs: docsChanged,
            run_control_ui_i18n: runControlUiI18n,
            run_skills_python_job: runSkillsPython,
            run_checks_windows: runWindows,
            checks_windows_matrix: createMatrix(
              runWindows
                ? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }]
                : [],
            ),
            run_macos_node: runMacos,
            macos_node_matrix: createMatrix(
              runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
            ),
            run_macos_swift: runMacos,
            run_android_job: runAndroid,
            android_matrix: createMatrix(
              runAndroid
                ? [
                    { check_name: "android-test-play", task: "test-play" },
                    { check_name: "android-test-third-party", task: "test-third-party" },
                    { check_name: "android-build-play", task: "build-play" },
                    { check_name: "android-build-third-party", task: "build-third-party" },
                  ]
                : [],
            ),
          };

          for (const [key, value] of Object.entries(manifest)) {
            appendFileSync(
              outputPath,
              `${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`,
              "utf8",
            );
          }
          EOF

  # Run the fast security/SCM checks in parallel with scope detection so the
  # main Node jobs do not have to wait for Python/pre-commit setup.
  security-fast:
    permissions:
      contents: read
    if: github.event_name != 'pull_request' || !github.event.pull_request.draft
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    env:
      PRE_COMMIT_CACHE_KEY_SUFFIX: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 1
          fetch-tags: false
          persist-credentials: false
          submodules: false

      - name: Ensure security 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: Prepare trusted pre-commit config
        if: github.event_name == 'pull_request'
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail
          trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
          git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
          echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"

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

      - 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') }}-${{ env.PRE_COMMIT_CACHE_KEY_SUFFIX }}
          restore-keys: |
            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==4.2.0

      - name: Detect committed private keys
        run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --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 --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"

      - name: Audit production dependencies
        run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files pnpm-audit-prod

  # Build dist once for Node-relevant changes and share it with downstream jobs.
  # Keep this overlapping with the fast correctness lanes so green PRs get heavy
  # test/build feedback sooner instead of waiting behind a full `check` pass.
  build-artifacts:
    permissions:
      contents: read
    needs: [preflight]
    if: needs.preflight.outputs.run_build_artifacts == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          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:ci-artifacts

      - name: Build Control UI
        run: pnpm ui:build

      - name: Cache dist build
        uses: actions/cache@v5
        with:
          path: dist/
          key: ${{ runner.os }}-dist-build-${{ github.sha }}

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

      - name: Upload A2UI bundle artifact
        uses: actions/upload-artifact@v7
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/
          include-hidden-files: true
          retention-days: 1

  checks-fast-core:
    permissions:
      contents: read
    name: ${{ matrix.check_name }}
    needs: [preflight]
    if: needs.preflight.outputs.run_checks_fast == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

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

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        env:
          OPENCLAW_TEST_PROJECTS_PARALLEL: 3
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            bundled)
              pnpm test:bundled
              ;;
            contracts|contracts-protocol)
              pnpm build
              pnpm test:contracts
              pnpm protocol:check
              ;;
            *)
              echo "Unsupported checks-fast task: $TASK" >&2
              exit 1
              ;;
          esac

  checks-node-extensions-shard:
    permissions:
      contents: read
    name: ${{ matrix.check_name }}
    needs: [preflight]
    if: needs.preflight.outputs.run_checks_fast == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.checks_node_extensions_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

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

      - name: Run extension shard
        env:
          OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
        run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"

  checks-node-extensions:
    permissions:
      contents: read
    name: checks-node-extensions
    needs: [preflight, checks-node-extensions-shard]
    if: always() && needs.preflight.outputs.run_checks_fast == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 5
    steps:
      - name: Verify extension shards
        env:
          SHARD_RESULT: ${{ needs.checks-node-extensions-shard.result }}
        run: |
          if [ "$SHARD_RESULT" != "success" ]; then
            echo "Extension shard checks failed: $SHARD_RESULT" >&2
            exit 1
          fi

  checks:
    permissions:
      contents: read
    name: ${{ matrix.check_name }}
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
    steps:
      - name: Skip compatibility lanes on pull requests
        if: github.event_name == 'pull_request' && matrix.task == 'compat-node22'
        run: echo "Skipping push-only lane on pull requests."

      - name: Checkout
        if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        if: github.event_name != 'pull_request' || 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: "false"
          use-sticky-disk: "false"

      - name: Configure Node test resources
        if: (github.event_name != 'pull_request' || matrix.task != 'compat-node22') && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'channels' || matrix.task == 'compat-node22')
        env:
          TASK: ${{ matrix.task }}
        run: |
          echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
          if [ "$TASK" = "test" ]; then
            echo "OPENCLAW_TEST_PROJECTS_LEAF_SHARDS=1" >> "$GITHUB_ENV"
            echo "OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD=1" >> "$GITHUB_ENV"
          fi
          if [ "$TASK" = "channels" ]; then
            echo "OPENCLAW_VITEST_MAX_WORKERS=1" >> "$GITHUB_ENV"
          fi

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

      - name: Download A2UI bundle artifact
        if: matrix.task == 'test' || matrix.task == 'channels'
        uses: actions/download-artifact@v8
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
        env:
          TASK: ${{ matrix.task }}
          NODE_OPTIONS: --max-old-space-size=6144
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            test)
              pnpm test
              ;;
            channels)
              pnpm test:channels
              ;;
            compat-node22)
              pnpm build
              pnpm ui:build
              node openclaw.mjs --help
              node openclaw.mjs status --json --timeout 1
              pnpm test:build:singleton
              ;;
            *)
              echo "Unsupported checks task: $TASK" >&2
              exit 1
              ;;
          esac

  checks-node-core-test-shard:
    permissions:
      contents: read
    name: ${{ matrix.check_name }}
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_test_matrix) }}
    steps:
      - name: Checkout
        shell: bash
        env:
          CHECKOUT_REPO: ${{ github.repository }}
          CHECKOUT_SHA: ${{ github.sha }}
          CHECKOUT_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail

          workdir="$GITHUB_WORKSPACE"
          auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"

          reset_checkout_dir() {
            mkdir -p "$workdir"
            find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
          }

          checkout_attempt() {
            local attempt="$1"

            reset_checkout_dir
            git init "$workdir" >/dev/null
            git config --global --add safe.directory "$workdir"
            git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
            git -C "$workdir" config gc.auto 0

            timeout --signal=TERM 30s git -C "$workdir" \
              -c protocol.version=2 \
              -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
              fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
              "+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1

            git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
            test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
            echo "checkout attempt ${attempt}/2 succeeded"
          }

          for attempt in 1 2; do
            if checkout_attempt "$attempt"; then
              exit 0
            fi
            echo "checkout attempt ${attempt}/2 failed"
            sleep $((attempt * 5))
          done

          echo "checkout failed after 2 attempts" >&2
          exit 1

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          node-version: "${{ matrix.node_version || '24.x' }}"
          cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Configure Node test resources
        run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"

      - name: Restore dist cache
        id: dist-cache
        if: matrix.requires_dist == true
        uses: actions/cache@v5
        with:
          path: dist/
          key: ${{ runner.os }}-dist-build-${{ github.sha }}

      - name: Download dist artifact
        if: matrix.requires_dist == true && steps.dist-cache.outputs.cache-hit != 'true'
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Download A2UI bundle artifact
        uses: actions/download-artifact@v8
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/

      - name: Run Node test shard
        env:
          NODE_OPTIONS: --max-old-space-size=6144
          OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
        shell: bash
        run: |
          set -euo pipefail
          node --input-type=module <<'EOF'
          import { spawnSync } from "node:child_process";
          import { resolveVitestCliEntry, resolveVitestNodeArgs } from "./scripts/run-vitest.mjs";

          const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]");
          if (!Array.isArray(configs) || configs.length === 0) {
            console.error("Missing node test shard configs");
            process.exit(1);
          }

          for (const config of configs) {
            console.error(`[test] starting ${config}`);
            const result = spawnSync(
              "pnpm",
              [
                "exec",
                "node",
                ...resolveVitestNodeArgs(process.env),
                resolveVitestCliEntry(),
                "run",
                "--config",
                config,
              ],
              {
                env: process.env,
                stdio: "inherit",
              },
            );
            if ((result.status ?? 1) !== 0) {
              process.exit(result.status ?? 1);
            }
          }
          EOF

  checks-node-core-test:
    permissions:
      contents: read
    name: checks-node-core
    needs: [preflight, checks-node-core-test-shard]
    if: always() && needs.preflight.outputs.run_checks == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 5
    steps:
      - name: Verify node test shards
        env:
          SHARD_RESULT: ${{ needs.checks-node-core-test-shard.result }}
        run: |
          if [ "$SHARD_RESULT" != "success" ]; then
            echo "Node test shards failed: $SHARD_RESULT" >&2
            exit 1
          fi

  extension-fast:
    permissions:
      contents: read
    name: "extension-fast"
    needs: [preflight]
    if: needs.preflight.outputs.run_extension_fast == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.extension_fast_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          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:
    permissions:
      contents: read
    name: "check"
    needs: [preflight]
    if: always() && needs.preflight.outputs.run_check == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          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
        env:
          OPENCLAW_LOCAL_CHECK: "0"
        run: pnpm check

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

  check-additional:
    permissions:
      contents: read
    name: "check-additional"
    needs: [preflight]
    if: always() && needs.preflight.outputs.run_check_additional == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          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 no-random-messaging guard
        id: no_random_messaging
        continue-on-error: true
        run: pnpm run lint:tmp:no-random-messaging

      - name: Run channel-agnostic boundary guard
        id: channel_agnostic_boundaries
        continue-on-error: true
        run: pnpm run lint:tmp:channel-agnostic-boundaries

      - name: Run no-raw-channel-fetch guard
        id: no_raw_channel_fetch
        continue-on-error: true
        run: pnpm run lint:tmp:no-raw-channel-fetch

      - name: Run ingress owner guard
        id: ingress_owner
        continue-on-error: true
        run: pnpm run lint:agent:ingress-owner

      - name: Run no-register-http-handler guard
        id: no_register_http_handler
        continue-on-error: true
        run: pnpm run lint:plugins:no-register-http-handler

      - name: Run no-monolithic plugin-sdk entry import guard
        id: no_monolithic_plugin_sdk_entry_imports
        continue-on-error: true
        run: pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports

      - name: Run no-extension-src-imports guard
        id: no_extension_src_imports
        continue-on-error: true
        run: pnpm run lint:plugins:no-extension-src-imports

      - name: Run no-extension-test-core-imports guard
        id: no_extension_test_core_imports
        continue-on-error: true
        run: pnpm run lint:plugins:no-extension-test-core-imports

      - name: Run plugin-sdk subpaths exported guard
        id: plugin_sdk_subpaths_exported
        continue-on-error: true
        run: pnpm run lint:plugins:plugin-sdk-subpaths-exported

      - 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 web fetch provider boundary guard
        id: web_fetch_provider_boundary
        continue-on-error: true
        run: pnpm run lint:web-fetch-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: Run extension relative-outside-package guard
        id: extension_relative_outside_package_boundary
        continue-on-error: true
        run: pnpm run lint:extensions:no-relative-outside-package

      - name: Run extension channel lint
        id: extension_channel_lint
        continue-on-error: true
        run: pnpm run lint:extensions:channels

      - name: Run bundled extension lint
        id: extension_bundled_lint
        continue-on-error: true
        run: pnpm run lint:extensions:bundled

      - name: Run extension package boundary TypeScript check
        id: extension_package_boundary_tsc
        continue-on-error: true
        env:
          OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
        run: pnpm run test:extensions:package-boundary

      - 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: Check control UI locale sync
        id: control_ui_i18n
        if: needs.preflight.outputs.run_control_ui_i18n == 'true'
        continue-on-error: true
        run: pnpm ui:i18n:check

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

      - name: Run import cycle guard
        id: import_cycles
        continue-on-error: true
        run: pnpm check:import-cycles

      - name: Run madge import cycle guard
        id: madge_import_cycles
        continue-on-error: true
        run: pnpm check:madge-import-cycles

      - 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 }}
          NO_RANDOM_MESSAGING_OUTCOME: ${{ steps.no_random_messaging.outcome }}
          CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME: ${{ steps.channel_agnostic_boundaries.outcome }}
          NO_RAW_CHANNEL_FETCH_OUTCOME: ${{ steps.no_raw_channel_fetch.outcome }}
          INGRESS_OWNER_OUTCOME: ${{ steps.ingress_owner.outcome }}
          NO_REGISTER_HTTP_HANDLER_OUTCOME: ${{ steps.no_register_http_handler.outcome }}
          NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME: ${{ steps.no_monolithic_plugin_sdk_entry_imports.outcome }}
          NO_EXTENSION_SRC_IMPORTS_OUTCOME: ${{ steps.no_extension_src_imports.outcome }}
          NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME: ${{ steps.no_extension_test_core_imports.outcome }}
          PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME: ${{ steps.plugin_sdk_subpaths_exported.outcome }}
          WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
          WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_fetch_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 }}
          EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
          EXTENSION_CHANNEL_LINT_OUTCOME: ${{ steps.extension_channel_lint.outcome }}
          EXTENSION_BUNDLED_LINT_OUTCOME: ${{ steps.extension_bundled_lint.outcome }}
          EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME: ${{ steps.extension_package_boundary_tsc.outcome }}
          NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
          CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome == 'skipped' && 'success' || steps.control_ui_i18n.outcome }}
          GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
          IMPORT_CYCLES_OUTCOME: ${{ steps.import_cycles.outcome }}
          MADGE_IMPORT_CYCLES_OUTCOME: ${{ steps.madge_import_cycles.outcome }}
        run: |
          failures=0
          for result in \
            "plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
            "lint:tmp:no-random-messaging|$NO_RANDOM_MESSAGING_OUTCOME" \
            "lint:tmp:channel-agnostic-boundaries|$CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME" \
            "lint:tmp:no-raw-channel-fetch|$NO_RAW_CHANNEL_FETCH_OUTCOME" \
            "lint:agent:ingress-owner|$INGRESS_OWNER_OUTCOME" \
            "lint:plugins:no-register-http-handler|$NO_REGISTER_HTTP_HANDLER_OUTCOME" \
            "lint:plugins:no-monolithic-plugin-sdk-entry-imports|$NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME" \
            "lint:plugins:no-extension-src-imports|$NO_EXTENSION_SRC_IMPORTS_OUTCOME" \
            "lint:plugins:no-extension-test-core-imports|$NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME" \
            "lint:plugins:plugin-sdk-subpaths-exported|$PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME" \
            "web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
            "web-fetch-provider-boundary|$WEB_FETCH_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" \
            "extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
            "lint:extensions:channels|$EXTENSION_CHANNEL_LINT_OUTCOME" \
            "lint:extensions:bundled|$EXTENSION_BUNDLED_LINT_OUTCOME" \
            "test:extensions:package-boundary|$EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME" \
            "lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
            "ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
            "gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME" \
            "check:import-cycles|$IMPORT_CYCLES_OUTCOME" \
            "check:madge-import-cycles|$MADGE_IMPORT_CYCLES_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:
    permissions:
      contents: read
    name: "build-smoke"
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_build_smoke == '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:
          persist-credentials: false
          submodules: false

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

      - name: Restore dist cache
        id: build-smoke-dist-cache
        if: github.event_name == 'push'
        uses: actions/cache@v5
        with:
          path: dist/
          key: ${{ runner.os }}-dist-build-${{ github.sha }}

      - name: Download dist artifact
        if: github.event_name == 'push' && steps.build-smoke-dist-cache.outputs.cache-hit != 'true'
        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: Smoke test built bundled runtime deps
        run: pnpm test:build:bundled-runtime-deps

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

  # Validate docs (format, lint, broken links) only when docs files changed.
  check-docs:
    permissions:
      contents: read
    needs: [preflight]
    if: needs.preflight.outputs.run_check_docs == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          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:
    permissions:
      contents: read
    needs: [preflight]
    if: needs.preflight.outputs.run_skills_python_job == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          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:
    permissions:
      contents: read
    name: ${{ matrix.check_name }}
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_checks_windows == 'true' && needs.build-artifacts.result == 'success'
    runs-on: blacksmith-32vcpu-windows-2025
    timeout-minutes: 60
    env:
      NODE_OPTIONS: --max-old-space-size=6144
      # Keep total concurrency predictable on the 32 vCPU runner.
      OPENCLAW_VITEST_MAX_WORKERS: 1
      OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD: 1
    defaults:
      run:
        shell: bash
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          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.32.1"
          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: Download dist artifact
        if: matrix.task == 'test'
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Download A2UI bundle artifact
        if: matrix.task == 'test'
        uses: actions/download-artifact@v8
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        env:
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            test)
              # Linux owns the full repo test suite. Keep the Windows runner focused on
              # Windows-native process/path wrappers so platform regressions fail fast.
              pnpm test:windows:ci
              ;;
            *)
              echo "Unsupported Windows checks task: $TASK" >&2
              exit 1
              ;;
          esac

  macos-node:
    permissions:
      contents: read
    name: ${{ matrix.check_name }}
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_macos_node == 'true' && needs.build-artifacts.result == 'success'
    runs-on: blacksmith-6vcpu-macos-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.macos_node_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

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

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

      - name: Download A2UI bundle artifact
        uses: actions/download-artifact@v8
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/

      - name: Patch mlx-audio-swift manifest
        run: |
          set -euo pipefail
          swift package resolve --package-path apps/macos >/dev/null
          chmod u+w apps/macos/.build/checkouts/mlx-audio-swift/Package.swift
          python <<'PY'
          from pathlib import Path

          path = Path("apps/macos/.build/checkouts/mlx-audio-swift/Package.swift")
          text = path.read_text()
          if "Models/Qwen3/README.md" in text:
              print("mlx-audio-swift README excludes already present")
              raise SystemExit(0)

          needle = '            path: "Sources/MLXAudioTTS"\n'
          replacement = """            path: \"Sources/MLXAudioTTS\",\n            exclude: [\n                \"Models/Llama/README.md\",\n                \"Models/Marvis/README.md\",\n                \"Models/PocketTTS/README.md\",\n                \"Models/Qwen3/README.md\",\n                \"Models/Soprano/README.md\",\n            ]\n"""

          if needle not in text:
              raise SystemExit("Could not find MLXAudioTTS target path in mlx-audio-swift Package.swift")

          path.write_text(text.replace(needle, replacement, 1))
          print(f"Patched {path}")
          PY

      - name: TS tests (macOS)
        env:
          NODE_OPTIONS: --max-old-space-size=4096
          OPENCLAW_VITEST_MAX_WORKERS: 2
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            test)
              # Linux owns the full repo test suite. Keep macOS CI focused on
              # launchd/Homebrew/runtime path coverage and the process-group wrapper.
              pnpm test:macos:ci
              ;;
            *)
              echo "Unsupported macOS node task: $TASK" >&2
              exit 1
              ;;
          esac

  macos-swift:
    permissions:
      contents: read
    name: "macos-swift"
    needs: [preflight]
    if: needs.preflight.outputs.run_macos_swift == 'true'
    runs-on: blacksmith-12vcpu-macos-latest
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Install XcodeGen / SwiftLint / SwiftFormat
        run: brew install xcodegen swiftlint 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: Patch mlx-audio-swift manifest
        run: |
          set -euo pipefail
          swift package resolve --package-path apps/macos >/dev/null
          chmod u+w apps/macos/.build/checkouts/mlx-audio-swift/Package.swift
          python <<'PY'
          from pathlib import Path

          path = Path("apps/macos/.build/checkouts/mlx-audio-swift/Package.swift")
          text = path.read_text()
          if "Models/Qwen3/README.md" in text:
              print("mlx-audio-swift README excludes already present")
              raise SystemExit(0)

          needle = '            path: "Sources/MLXAudioTTS"\n'
          replacement = """            path: \"Sources/MLXAudioTTS\",\n            exclude: [\n                \"Models/Llama/README.md\",\n                \"Models/Marvis/README.md\",\n                \"Models/PocketTTS/README.md\",\n                \"Models/Qwen3/README.md\",\n                \"Models/Soprano/README.md\",\n            ]\n"""

          if needle not in text:
              raise SystemExit("Could not find MLXAudioTTS target path in mlx-audio-swift Package.swift")

          path.write_text(text.replace(needle, replacement, 1))
          print(f"Patched {path}")
          PY

      - 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: 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:
    permissions:
      contents: read
    name: ${{ matrix.check_name }}
    needs: [preflight]
    if: needs.preflight.outputs.run_android_job == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          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@0723195856401067f7a2779048b490ace7a47d7c # 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
        env:
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            test-play)
              ./gradlew --no-daemon :app:testPlayDebugUnitTest
              ;;
            test-third-party)
              ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
              ;;
            build-play)
              ./gradlew --no-daemon :app:assemblePlayDebug
              ;;
            build-third-party)
              ./gradlew --no-daemon :app:assembleThirdPartyDebug
              ;;
            *)
              echo "Unsupported Android task: $TASK" >&2
              exit 1
              ;;
          esac
