feat: comprehensive zedot ecosystem config v0.0.1 #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main, zedot] | |
| pull_request: | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| jobs: | |
| # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). | |
| # Lint and format always run. Fail-safe: if detection fails, run everything. | |
| docs-scope: | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| outputs: | |
| docs_only: ${{ steps.check.outputs.docs_only }} | |
| docs_changed: ${{ steps.check.outputs.docs_changed }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 1 | |
| fetch-tags: false | |
| submodules: false | |
| - name: Ensure docs-scope 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: check | |
| uses: ./.github/actions/detect-docs-changes | |
| # Detect which heavy areas are touched so CI can skip unrelated expensive jobs. | |
| # Fail-safe: if detection fails, downstream jobs run. | |
| changed-scope: | |
| needs: [docs-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| outputs: | |
| run_node: ${{ steps.scope.outputs.run_node }} | |
| run_macos: ${{ steps.scope.outputs.run_macos }} | |
| run_android: ${{ steps.scope.outputs.run_android }} | |
| run_skills_python: ${{ steps.scope.outputs.run_skills_python }} | |
| run_windows: ${{ steps.scope.outputs.run_windows }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 1 | |
| fetch-tags: false | |
| submodules: false | |
| - name: Ensure changed-scope 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 changed scopes | |
| id: scope | |
| 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 | |
| changed-extensions: | |
| needs: [docs-scope, changed-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| outputs: | |
| has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }} | |
| changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 1 | |
| fetch-tags: false | |
| submodules: false | |
| - name: Ensure changed-extensions 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: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| with: | |
| install-bun: "false" | |
| install-deps: "false" | |
| use-sticky-disk: "false" | |
| - name: Detect changed extensions | |
| id: changed | |
| env: | |
| BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} | |
| 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" }); | |
| 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 | |
| # Build dist once for Node-relevant changes and share it with downstream jobs. | |
| build-artifacts: | |
| needs: [docs-scope, changed-scope] | |
| if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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: [docs-scope, build-artifacts] | |
| if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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: [docs-scope, changed-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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 | |
| pnpm test | |
| 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: 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: [docs-scope, changed-scope, changed-extensions] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.changed-extensions.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: [docs-scope, changed-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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: [docs-scope, changed-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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: 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 }} | |
| 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"; 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: [docs-scope, changed-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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: Build dist | |
| 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: [docs-scope] | |
| if: needs.docs-scope.outputs.docs_changed == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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: [docs-scope, changed-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_skills_python == 'true') | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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 | |
| secrets: | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| submodules: false | |
| - name: Ensure secrets 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: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| with: | |
| install-bun: "false" | |
| use-sticky-disk: "false" | |
| install-deps: "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') }} | |
| - 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 | |
| run: pre-commit run --all-files pnpm-audit-prod | |
| checks-windows: | |
| needs: [docs-scope, changed-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_windows == 'true' | |
| runs-on: blacksmith-32vcpu-windows-2025 | |
| timeout-minutes: 45 | |
| 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: 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: [docs-scope, changed-scope, check] | |
| if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true' | |
| runs-on: macos-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| submodules: false | |
| - name: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| with: | |
| install-bun: "false" | |
| # --- 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 | |
| ios: | |
| if: false # ignore iOS in CI for now | |
| runs-on: macos-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| submodules: false | |
| - name: Select Xcode 26.1 | |
| run: | | |
| sudo xcode-select -s /Applications/Xcode_26.1.app | |
| xcodebuild -version | |
| - name: Install XcodeGen | |
| run: brew install xcodegen | |
| - name: Install SwiftLint / SwiftFormat | |
| run: brew install swiftlint swiftformat | |
| - name: Show toolchain | |
| run: | | |
| sw_vers | |
| xcodebuild -version | |
| swift --version | |
| - name: Generate iOS project | |
| run: | | |
| cd apps/ios | |
| xcodegen generate | |
| - name: iOS tests | |
| run: | | |
| set -euo pipefail | |
| RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" | |
| DEST_ID="$( | |
| python3 - <<'PY' | |
| import json | |
| import subprocess | |
| import sys | |
| import uuid | |
| def sh(args: list[str]) -> str: | |
| return subprocess.check_output(args, text=True).strip() | |
| # Prefer an already-created iPhone simulator if it exists. | |
| devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"])) | |
| candidates: list[tuple[str, str]] = [] | |
| for runtime, devs in (devices.get("devices") or {}).items(): | |
| for dev in devs or []: | |
| if not dev.get("isAvailable"): | |
| continue | |
| name = str(dev.get("name") or "") | |
| udid = str(dev.get("udid") or "") | |
| if not udid or not name.startswith("iPhone"): | |
| continue | |
| candidates.append((name, udid)) | |
| candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0])) | |
| if candidates: | |
| print(candidates[0][1]) | |
| sys.exit(0) | |
| # Otherwise, create one from the newest available iOS runtime. | |
| runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or [] | |
| ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")] | |
| if not ios: | |
| print("No available iOS runtimes found.", file=sys.stderr) | |
| sys.exit(1) | |
| def version_key(rt: dict) -> tuple[int, ...]: | |
| parts: list[int] = [] | |
| for p in str(rt.get("version") or "0").split("."): | |
| try: | |
| parts.append(int(p)) | |
| except ValueError: | |
| parts.append(0) | |
| return tuple(parts) | |
| ios.sort(key=version_key, reverse=True) | |
| runtime = ios[0] | |
| runtime_id = str(runtime.get("identifier") or "") | |
| if not runtime_id: | |
| print("Missing iOS runtime identifier.", file=sys.stderr) | |
| sys.exit(1) | |
| supported = runtime.get("supportedDeviceTypes") or [] | |
| iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"] | |
| if not iphones: | |
| print("No iPhone device types for iOS runtime.", file=sys.stderr) | |
| sys.exit(1) | |
| iphones.sort( | |
| key=lambda dt: ( | |
| 0 if "iPhone 16" in str(dt.get("name") or "") else 1, | |
| str(dt.get("name") or ""), | |
| ) | |
| ) | |
| device_type_id = str(iphones[0].get("identifier") or "") | |
| if not device_type_id: | |
| print("Missing iPhone device type identifier.", file=sys.stderr) | |
| sys.exit(1) | |
| sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}" | |
| udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id]) | |
| if not udid: | |
| print("Failed to create iPhone simulator.", file=sys.stderr) | |
| sys.exit(1) | |
| print(udid) | |
| PY | |
| )" | |
| echo "Using iOS Simulator id: $DEST_ID" | |
| xcodebuild test \ | |
| -project apps/ios/Clawdis.xcodeproj \ | |
| -scheme Clawdis \ | |
| -destination "platform=iOS Simulator,id=$DEST_ID" \ | |
| -resultBundlePath "$RESULT_BUNDLE_PATH" \ | |
| -enableCodeCoverage YES | |
| - name: iOS coverage summary | |
| run: | | |
| set -euo pipefail | |
| RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" | |
| xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH" | |
| - name: iOS coverage gate (43%) | |
| run: | | |
| set -euo pipefail | |
| RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" | |
| RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY' | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| target_name = "Clawdis.app" | |
| minimum = 0.43 | |
| report = json.loads( | |
| subprocess.check_output( | |
| ["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]], | |
| text=True, | |
| ) | |
| ) | |
| target_coverage = None | |
| for target in report.get("targets", []): | |
| if target.get("name") == target_name: | |
| target_coverage = float(target["lineCoverage"]) | |
| break | |
| if target_coverage is None: | |
| print(f"Could not find coverage for target: {target_name}") | |
| sys.exit(1) | |
| print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)") | |
| if target_coverage + 1e-12 < minimum: | |
| sys.exit(1) | |
| PY | |
| android: | |
| needs: [docs-scope, changed-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_android == 'true' | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 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 }} |