name: CI on: push: branches: [main] pull_request: concurrency: group: ci-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: 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: ubuntu-latest outputs: docs_only: ${{ steps.check.outputs.docs_only }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 submodules: false - name: Detect docs-only changes id: check uses: ./.github/actions/detect-docs-only # Detect which heavy areas are touched so PRs can skip unrelated expensive jobs. # Push to main keeps broad coverage. changed-scope: needs: [docs-scope] if: needs.docs-scope.outputs.docs_only != 'true' runs-on: ubuntu-latest outputs: run_node: ${{ steps.scope.outputs.run_node }} run_macos: ${{ steps.scope.outputs.run_macos }} run_android: ${{ steps.scope.outputs.run_android }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 submodules: false - 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 CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")" if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then # Fail-safe: run broad checks if detection fails. echo "run_node=true" >> "$GITHUB_OUTPUT" echo "run_macos=true" >> "$GITHUB_OUTPUT" echo "run_android=true" >> "$GITHUB_OUTPUT" exit 0 fi run_node=false run_macos=false run_android=false has_non_docs=false has_non_native_non_docs=false while IFS= read -r path; do [ -z "$path" ] && continue case "$path" in docs/*|*.md|*.mdx) continue ;; *) has_non_docs=true ;; esac case "$path" in apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*) run_macos=true ;; esac case "$path" in apps/android/*|apps/shared/*) run_android=true ;; esac case "$path" in src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc) run_node=true ;; esac case "$path" in apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml) ;; *) has_non_native_non_docs=true ;; esac done <<< "$CHANGED" # If there are non-doc files outside native app trees, keep Node checks enabled. if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then run_node=true fi echo "run_node=${run_node}" >> "$GITHUB_OUTPUT" echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT" echo "run_android=${run_android}" >> "$GITHUB_OUTPUT" # Build dist once for Node-relevant changes and share it with downstream jobs. build-artifacts: needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 with: submodules: false - name: Checkout submodules (retry) run: | set -euo pipefail git submodule sync --recursive for attempt in 1 2 3 4 5; do if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then exit 0 fi echo "Submodule update failed (attempt $attempt/5). Retrying…" sleep $((attempt * 10)) done exit 1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.x check-latest: true - name: Setup pnpm + cache store uses: ./.github/actions/setup-pnpm-store-cache with: pnpm-version: "10.23.0" cache-key-suffix: "node22" - 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 (frozen) env: CI: true run: | export PATH="$NODE_BIN:$PATH" which node node -v pnpm -v pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true - name: Build dist run: pnpm build - name: Upload dist artifact uses: actions/upload-artifact@v4 with: name: dist-build path: dist/ retention-days: 1 install-check: needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 with: submodules: false - name: Checkout submodules (retry) run: | set -euo pipefail git submodule sync --recursive for attempt in 1 2 3 4 5; do if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then exit 0 fi echo "Submodule update failed (attempt $attempt/5). Retrying…" sleep $((attempt * 10)) done exit 1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.x check-latest: true - name: Setup pnpm + cache store uses: ./.github/actions/setup-pnpm-store-cache with: pnpm-version: "10.23.0" cache-key-suffix: "node22" - 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 (frozen) env: CI: true run: | export PATH="$NODE_BIN:$PATH" which node node -v pnpm -v pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true checks: needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') runs-on: blacksmith-4vcpu-ubuntu-2404 strategy: fail-fast: false matrix: include: - runtime: node task: tsgo command: pnpm tsgo - runtime: node task: test command: pnpm canvas:a2ui:bundle && pnpm test - 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 steps: - name: Checkout uses: actions/checkout@v4 with: submodules: false - name: Checkout submodules (retry) run: | set -euo pipefail git submodule sync --recursive for attempt in 1 2 3 4 5; do if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then exit 0 fi echo "Submodule update failed (attempt $attempt/5). Retrying…" sleep $((attempt * 10)) done exit 1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.x check-latest: true - name: Setup pnpm + cache store uses: ./.github/actions/setup-pnpm-store-cache with: pnpm-version: "10.23.0" cache-key-suffix: "node22" - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Runtime versions run: | node -v npm -v bun -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 pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) run: ${{ matrix.command }} # Lint and format always run, even on docs-only changes. checks-lint: runs-on: blacksmith-4vcpu-ubuntu-2404 strategy: fail-fast: false matrix: include: - task: lint command: pnpm lint - task: format command: pnpm format steps: - name: Checkout uses: actions/checkout@v4 with: submodules: false - name: Checkout submodules (retry) run: | set -euo pipefail git submodule sync --recursive for attempt in 1 2 3 4 5; do if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then exit 0 fi echo "Submodule update failed (attempt $attempt/5). Retrying…" sleep $((attempt * 10)) done exit 1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.x check-latest: true - name: Setup pnpm + cache store uses: ./.github/actions/setup-pnpm-store-cache with: pnpm-version: "10.23.0" cache-key-suffix: "node22" - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Runtime versions run: | node -v npm -v bun -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 pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true - name: Run ${{ matrix.task }} run: ${{ matrix.command }} secrets: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 with: submodules: false - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install detect-secrets run: | python -m pip install --upgrade pip python -m pip install detect-secrets==1.5.0 - name: Detect secrets run: | if ! detect-secrets scan --baseline .secrets.baseline; then echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets" exit 1 fi checks-windows: needs: [docs-scope, changed-scope, build-artifacts] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') runs-on: blacksmith-4vcpu-windows-2025 env: NODE_OPTIONS: --max-old-space-size=4096 # Keep total concurrency predictable on the 4 vCPU runner: # `scripts/test-parallel.mjs` runs some vitest suites in parallel processes. OPENCLAW_TEST_WORKERS: 2 defaults: run: shell: bash strategy: fail-fast: false matrix: include: - runtime: node task: lint command: pnpm lint - runtime: node task: test command: pnpm canvas:a2ui:bundle && pnpm test - runtime: node task: protocol command: pnpm protocol:check steps: - name: Checkout uses: actions/checkout@v4 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: Checkout submodules (retry) run: | set -euo pipefail git submodule sync --recursive for attempt in 1 2 3 4 5; do if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then exit 0 fi echo "Submodule update failed (attempt $attempt/5). Retrying…" sleep $((attempt * 10)) done exit 1 - name: Download dist artifact (lint lane) if: matrix.task == 'lint' uses: actions/download-artifact@v4 with: name: dist-build path: dist/ - name: Verify dist artifact (lint lane) if: matrix.task == 'lint' run: | set -euo pipefail test -s dist/index.js test -s dist/plugin-sdk/index.js - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.x check-latest: true - name: Setup pnpm + cache store uses: ./.github/actions/setup-pnpm-store-cache with: pnpm-version: "10.23.0" cache-key-suffix: "node22" - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Runtime versions run: | node -v npm -v bun -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 pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true - 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] 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@v4 with: submodules: false - name: Checkout submodules (retry) run: | set -euo pipefail git submodule sync --recursive for attempt in 1 2 3 4 5; do if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then exit 0 fi echo "Submodule update failed (attempt $attempt/5). Retrying…" sleep $((attempt * 10)) done exit 1 # --- Node/pnpm setup (for TS tests) --- - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.x check-latest: true - name: Setup pnpm + cache store uses: ./.github/actions/setup-pnpm-store-cache with: pnpm-version: "10.23.0" cache-key-suffix: "node22" - 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 pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true # --- 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: 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@v4 with: submodules: false - name: Checkout submodules (retry) run: | set -euo pipefail git submodule sync --recursive for attempt in 1 2 3 4 5; do if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then exit 0 fi echo "Submodule update failed (attempt $attempt/5). Retrying…" sleep $((attempt * 10)) done exit 1 - 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' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true') runs-on: blacksmith-4vcpu-ubuntu-2404 strategy: fail-fast: false matrix: include: - task: test command: ./gradlew --no-daemon :app:testDebugUnitTest - task: build command: ./gradlew --no-daemon :app:assembleDebug steps: - name: Checkout uses: actions/checkout@v4 with: submodules: false - name: Checkout submodules (retry) run: | set -euo pipefail git submodule sync --recursive for attempt in 1 2 3 4 5; do if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then exit 0 fi echo "Submodule update failed (attempt $attempt/5). Retrying…" sleep $((attempt * 10)) done exit 1 - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - name: Setup Android SDK uses: android-actions/setup-android@v3 with: accept-android-sdk-licenses: false - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: gradle-version: 8.11.1 - name: Install Android SDK packages run: | yes | sdkmanager --licenses >/dev/null sdkmanager --install \ "platform-tools" \ "platforms;android-36" \ "build-tools;36.0.0" - name: Run Android ${{ matrix.task }} working-directory: apps/android run: ${{ matrix.command }}